Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 3 additions & 27 deletions arazzo/criterion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
279 changes: 279 additions & 0 deletions arazzo/criterion_boolean.go
Original file line number Diff line number Diff line change
@@ -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")
}
37 changes: 37 additions & 0 deletions arazzo/criterion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
}
10 changes: 10 additions & 0 deletions arazzo/step.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading