diff --git a/arazzo/criterion.go b/arazzo/criterion.go index c78ec75e..696ed343 100644 --- a/arazzo/criterion.go +++ b/arazzo/criterion.go @@ -97,33 +97,9 @@ func evaluateSimpleConditionString(condition string, exprCtx *expression.Context if trimmed == "" { return false, nil } - - if b, err := strconv.ParseBool(trimmed); err == nil { - return b, nil - } - - leftRaw, op, rightRaw, found := splitSimpleCondition(trimmed) - if found { - left, err := evaluateSimpleOperand(leftRaw, exprCtx, caches) - if err != nil { - return false, err - } - right, err := evaluateSimpleOperand(rightRaw, exprCtx, caches) - if err != nil { - return false, err - } - return compareSimpleValues(left, right, op) - } - - val, err := evaluateSimpleOperand(trimmed, exprCtx, caches) - if err != nil { - return false, err - } - b, ok := val.(bool) - if !ok { - return false, fmt.Errorf("simple condition %q did not evaluate to a boolean", condition) - } - return b, nil + // Compound simple conditions may combine literals, operators, and runtime + // expressions with &&, ||, and parentheses (Arazzo 1.0.1 Criterion Object). + return evaluateBooleanExpr(trimmed, exprCtx, caches) } func splitSimpleCondition(input string) (left, op, right string, found bool) { diff --git a/arazzo/criterion_boolean.go b/arazzo/criterion_boolean.go new file mode 100644 index 00000000..dae47451 --- /dev/null +++ b/arazzo/criterion_boolean.go @@ -0,0 +1,279 @@ +// Copyright 2022-2026 Princess Beef Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package arazzo + +import ( + "fmt" + "strings" + "unicode" + + "github.com/pb33f/libopenapi/arazzo/expression" +) + +type boolTokenKind int + +const ( + tokEOF boolTokenKind = iota + tokOperand + tokAnd + tokOr + tokEq + tokNeq + tokGt + tokLt + tokGte + tokLte + tokLParen + tokRParen +) + +type boolToken struct { + kind boolTokenKind + raw string +} + +func evaluateBooleanExpr(condition string, exprCtx *expression.Context, caches *criterionCaches) (bool, error) { + tokens, err := tokenizeSimpleCondition(condition) + if err != nil { + return false, err + } + p := &boolParser{ + tokens: tokens, + exprCtx: exprCtx, + caches: caches, + input: condition, + } + value, err := p.parseOr() + if err != nil { + return false, err + } + if p.peek().kind != tokEOF { + return false, fmt.Errorf("unexpected token %q in simple condition %q", p.peek().raw, condition) + } + return value, nil +} + +type boolParser struct { + tokens []boolToken + pos int + exprCtx *expression.Context + caches *criterionCaches + input string +} + +func (p *boolParser) peek() boolToken { + if p.pos >= len(p.tokens) { + return boolToken{kind: tokEOF} + } + return p.tokens[p.pos] +} + +func (p *boolParser) next() boolToken { + tok := p.peek() + if tok.kind != tokEOF { + p.pos++ + } + return tok +} + +func (p *boolParser) parseOr() (bool, error) { + left, err := p.parseAnd() + if err != nil { + if !isOptionalRuntimeValueError(err) { + return false, err + } + left = false + } + for p.peek().kind == tokOr { + p.next() + right, err := p.parseAnd() + if err != nil { + if !isOptionalRuntimeValueError(err) { + return false, err + } + right = false + } + left = left || right + } + return left, nil +} + +func (p *boolParser) parseAnd() (bool, error) { + left, err := p.parseComparison() + if err != nil { + if !isOptionalRuntimeValueError(err) { + return false, err + } + left = false + } + for p.peek().kind == tokAnd { + p.next() + right, err := p.parseComparison() + if err != nil { + if !isOptionalRuntimeValueError(err) { + return false, err + } + right = false + } + left = left && right + } + return left, nil +} + +func (p *boolParser) parseComparison() (bool, error) { + left, err := p.parsePrimary() + if err != nil { + return false, err + } + if op, ok := comparisonOp(p.peek().kind); ok { + p.next() + right, err := p.parsePrimary() + if err != nil { + return false, err + } + return compareSimpleValues(left, right, op) + } + b, ok := left.(bool) + if !ok { + return false, fmt.Errorf("simple condition %q did not evaluate to a boolean", p.input) + } + return b, nil +} + +func (p *boolParser) parsePrimary() (any, error) { + tok := p.next() + switch tok.kind { + case tokLParen: + value, err := p.parseOr() + if err != nil { + return nil, err + } + if p.next().kind != tokRParen { + return nil, fmt.Errorf("missing closing parenthesis in simple condition %q", p.input) + } + return value, nil + case tokOperand: + value, err := evaluateSimpleOperand(tok.raw, p.exprCtx, p.caches) + if err != nil && isOptionalRuntimeValueError(err) { + return nil, nil + } + return value, err + default: + return nil, fmt.Errorf("unexpected token %q in simple condition %q", tok.raw, p.input) + } +} + +func comparisonOp(kind boolTokenKind) (string, bool) { + switch kind { + case tokEq: + return "==", true + case tokNeq: + return "!=", true + case tokGte: + return ">=", true + case tokLte: + return "<=", true + case tokGt: + return ">", true + case tokLt: + return "<", true + default: + return "", false + } +} + +func tokenizeSimpleCondition(input string) ([]boolToken, error) { + var tokens []boolToken + i := 0 + for i < len(input) { + for i < len(input) && unicode.IsSpace(rune(input[i])) { + i++ + } + if i >= len(input) { + break + } + rest := input[i:] + switch { + case strings.HasPrefix(rest, "&&"): + tokens = append(tokens, boolToken{kind: tokAnd, raw: "&&"}) + i += 2 + case strings.HasPrefix(rest, "||"): + tokens = append(tokens, boolToken{kind: tokOr, raw: "||"}) + i += 2 + case strings.HasPrefix(rest, "=="): + tokens = append(tokens, boolToken{kind: tokEq, raw: "=="}) + i += 2 + case strings.HasPrefix(rest, "!="): + tokens = append(tokens, boolToken{kind: tokNeq, raw: "!="}) + i += 2 + case strings.HasPrefix(rest, ">="): + tokens = append(tokens, boolToken{kind: tokGte, raw: ">="}) + i += 2 + case strings.HasPrefix(rest, "<="): + tokens = append(tokens, boolToken{kind: tokLte, raw: "<="}) + i += 2 + case rest[0] == '>': + tokens = append(tokens, boolToken{kind: tokGt, raw: ">"}) + i++ + case rest[0] == '<': + tokens = append(tokens, boolToken{kind: tokLt, raw: "<"}) + i++ + case rest[0] == '(': + tokens = append(tokens, boolToken{kind: tokLParen, raw: "("}) + i++ + case rest[0] == ')': + tokens = append(tokens, boolToken{kind: tokRParen, raw: ")"}) + i++ + default: + start := i + if rest[0] == '\'' || rest[0] == '"' { + q := rest[0] + i++ + for i < len(input) && input[i] != q { + i++ + } + if i >= len(input) { + return nil, fmt.Errorf("unterminated string in simple condition %q", input) + } + i++ + } else if rest[0] == '$' { + i++ + for i < len(input) && !isBoolOpStart(input[i:]) && input[i] != '(' && input[i] != ')' && !unicode.IsSpace(rune(input[i])) { + i++ + } + } else { + for i < len(input) && !isBoolOpStart(input[i:]) && input[i] != '(' && input[i] != ')' && !unicode.IsSpace(rune(input[i])) { + i++ + } + } + tokens = append(tokens, boolToken{kind: tokOperand, raw: input[start:i]}) + } + } + return tokens, nil +} + +func isBoolOpStart(s string) bool { + if s == "" { + return false + } + return strings.HasPrefix(s, "&&") || + strings.HasPrefix(s, "||") || + strings.HasPrefix(s, "==") || + strings.HasPrefix(s, "!=") || + strings.HasPrefix(s, ">=") || + strings.HasPrefix(s, "<=") || + s[0] == '>' || + s[0] == '<' +} + +func isOptionalRuntimeValueError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + return strings.Contains(msg, "not found") || + strings.Contains(msg, "no response body available") || + strings.Contains(msg, "no request body available") || + strings.Contains(msg, "no response headers available") +} diff --git a/arazzo/criterion_test.go b/arazzo/criterion_test.go index 85e219b4..7e800d56 100644 --- a/arazzo/criterion_test.go +++ b/arazzo/criterion_test.go @@ -10,6 +10,7 @@ import ( high "github.com/pb33f/libopenapi/datamodel/high/arazzo" "github.com/pb33f/testify/assert" "github.com/pb33f/testify/require" + "go.yaml.in/yaml/v4" ) func TestEvaluateCriterion_SimpleCondition_StatusCodeComparison(t *testing.T) { @@ -35,3 +36,39 @@ func TestEvaluateCriterion_SimpleCondition_StringComparison(t *testing.T) { require.NoError(t, err) assert.True(t, ok) } + +func TestEvaluateCriterion_SimpleCondition_BooleanOperators(t *testing.T) { + criterion := &high.Criterion{ + Condition: `$statusCode == 204 || ($statusCode == 200 && ($response.body#/challengeName == 'NEW_PASSWORD_REQUIRED' || $response.body#/challengeName == 'SOFTWARE_TOKEN_MFA'))`, + } + + ok, err := EvaluateCriterion(criterion, &expression.Context{StatusCode: 204}) + require.NoError(t, err) + assert.True(t, ok, "204 with an empty body should pass") + + ok, err = EvaluateCriterion(criterion, &expression.Context{ + StatusCode: 200, + ResponseBody: yamlMapping(t, map[string]any{"challengeName": "NEW_PASSWORD_REQUIRED"}), + }) + require.NoError(t, err) + assert.True(t, ok) + + ok, err = EvaluateCriterion(criterion, &expression.Context{ + StatusCode: 200, + ResponseBody: yamlMapping(t, map[string]any{"challengeName": "MFA_SETUP"}), + }) + require.NoError(t, err) + assert.False(t, ok, "unsupported challenge must fail") +} + +func yamlMapping(t *testing.T, v any) *yaml.Node { + t.Helper() + b, err := yaml.Marshal(v) + require.NoError(t, err) + var node yaml.Node + require.NoError(t, yaml.Unmarshal(b, &node)) + if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { + return node.Content[0] + } + return &node +} diff --git a/arazzo/step.go b/arazzo/step.go index fad8b3ce..7804375f 100644 --- a/arazzo/step.go +++ b/arazzo/step.go @@ -451,6 +451,11 @@ func (e *Engine) populateStepOutputs(step *high.Step, result *StepResult, exprCt for name, outputExpression := range step.Outputs.FromOldest() { value, err := e.evaluateStringValue(outputExpression, exprCtx) if err != nil { + // Missing body/header values are valid on empty responses (e.g. HTTP 204). + if isOptionalRuntimeValueError(err) { + result.Outputs[name] = nil + continue + } return fmt.Errorf("failed to evaluate output %q for step %q: %w", name, step.StepId, err) } result.Outputs[name] = value @@ -465,6 +470,11 @@ func (e *Engine) populateWorkflowOutputs(wf *high.Workflow, result *WorkflowResu for name, outputExpression := range wf.Outputs.FromOldest() { value, err := e.evaluateStringValue(outputExpression, exprCtx) if err != nil { + if isOptionalRuntimeValueError(err) { + result.Outputs[name] = nil + exprCtx.Outputs[name] = nil + continue + } return fmt.Errorf("failed to evaluate output %q for workflow %q: %w", name, wf.WorkflowId, err) } result.Outputs[name] = value