Skip to main content

roas_arazzo_executor/
criterion.rs

1//! Deciding whether a criterion holds.
2//!
3//! Per [Criterion Object](https://spec.openapis.org/arazzo/v1.1.0.html#criterion-object):
4//! `simple` conditions are a small expression language of their own,
5//! `regex` and `jsonpath` apply to the data the criterion's `context`
6//! names, and `xpath` is not supported here.
7//!
8//! The `simple` language is parsed rather than pattern-matched:
9//! comparisons of literals and runtime expressions, joined by `&&` and
10//! `||`, grouped by parentheses. An operand standing alone is read for
11//! its truth, which is what makes `$response.body#/ok` a condition.
12
13use crate::expression::{self, ExpressionError, Scope};
14use crate::select::{self, Language, SelectError};
15use roas_arazzo::v1_1::{Criterion, CriterionKind, CriterionType, ExpressionKind};
16use serde_json::Value;
17use std::cmp::Ordering;
18
19/// Why a criterion could not be decided.
20///
21/// A criterion that is simply *false* is not an error — it is the
22/// answer.
23#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
24pub enum CriterionError {
25    /// A runtime expression in the criterion could not be evaluated.
26    #[error(transparent)]
27    Expression(#[from] ExpressionError),
28    /// The criterion's selector could not be applied.
29    #[error(transparent)]
30    Select(#[from] SelectError),
31    /// The `simple` condition does not parse.
32    #[error("`{condition}` is not a valid condition: {message}")]
33    Syntax {
34        /// The condition as written.
35        condition: String,
36        /// What the parser objected to.
37        message: String,
38    },
39    /// The `regex` condition is not a valid regular expression.
40    #[error("`{condition}` is not a valid regular expression: {message}")]
41    Regex {
42        /// The condition as written.
43        condition: String,
44        /// What the regex engine said.
45        message: String,
46    },
47    /// A typed criterion arrived without the `context` it needs.
48    #[error("a `{0}` criterion needs a `context`")]
49    MissingContext(&'static str),
50    /// XPath, which this crate does not evaluate.
51    #[error("{0} criteria are not supported by this executor")]
52    Unsupported(&'static str),
53}
54
55/// Whether `criterion` holds in `scope`.
56pub(crate) fn passes(criterion: &Criterion, scope: &Scope<'_>) -> Result<bool, CriterionError> {
57    let context = |what: &'static str| -> Result<Value, CriterionError> {
58        let context = criterion
59            .context
60            .as_deref()
61            .ok_or(CriterionError::MissingContext(what))?;
62        Ok(expression::evaluate(context, scope)?)
63    };
64
65    // A pattern or a path may be written with expressions inside it —
66    // `{$inputs.pattern}` — and must be filled in before the engine
67    // that reads it ever sees it. A `simple` condition is different:
68    // its own parser evaluates the expressions it finds.
69    let written = || -> Result<String, CriterionError> {
70        Ok(expression::interpolate(&criterion.condition, scope)?)
71    };
72
73    match criterion.type_.as_ref() {
74        None | Some(CriterionType::Simple(CriterionKind::Simple)) => {
75            simple(&criterion.condition, scope)
76        }
77        Some(CriterionType::Simple(CriterionKind::Regex)) => regex(&written()?, &context("regex")?),
78        Some(CriterionType::Simple(CriterionKind::Jsonpath)) => {
79            selects(Language::Path, &written()?, &context("jsonpath")?)
80        }
81        Some(CriterionType::Simple(CriterionKind::Xpath)) => {
82            Err(CriterionError::Unsupported("XPath"))
83        }
84        Some(CriterionType::Expression(expression)) => match expression.type_ {
85            ExpressionKind::Jsonpath => selects(Language::Path, &written()?, &context("jsonpath")?),
86            ExpressionKind::Jsonpointer => {
87                selects(Language::Pointer, &written()?, &context("jsonpointer")?)
88            }
89            ExpressionKind::Xpath => Err(CriterionError::Unsupported("XPath")),
90        },
91    }
92}
93
94/// Whether the expression picks anything out of the context.
95///
96/// The specification is explicit: a condition passes when the
97/// expression returns a non-empty nodelist and fails when it returns an
98/// empty one. What was found does not matter — a node holding `false`
99/// is still a node, and a filter is how a criterion asks about a value.
100fn selects(language: Language, condition: &str, context: &Value) -> Result<bool, CriterionError> {
101    Ok(select::apply(language, condition, context)?.is_some())
102}
103
104fn regex(condition: &str, context: &Value) -> Result<bool, CriterionError> {
105    let regex = regex::Regex::new(condition).map_err(|error| CriterionError::Regex {
106        condition: condition.to_owned(),
107        message: error.to_string(),
108    })?;
109    Ok(regex.is_match(&text(context)))
110}
111
112/// A value as the text a regular expression is matched against: a
113/// string as it stands, anything else as its JSON.
114fn text(value: &Value) -> String {
115    match value {
116        Value::String(text) => text.clone(),
117        other => other.to_string(),
118    }
119}
120
121/// Whether a value counts as true where a condition wants a truth.
122fn truthy(value: &Value) -> bool {
123    match value {
124        Value::Null => false,
125        Value::Bool(bool) => *bool,
126        Value::Number(number) => number.as_f64().is_some_and(|number| number != 0.0),
127        Value::String(text) => !text.is_empty(),
128        Value::Array(items) => !items.is_empty(),
129        Value::Object(members) => !members.is_empty(),
130    }
131}
132
133// ---- the `simple` condition language --------------------------------
134
135#[derive(Clone, Debug, PartialEq)]
136enum Token {
137    Open,
138    Close,
139    And,
140    Or,
141    Compare(Comparison),
142    Value(Operand),
143}
144
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146enum Comparison {
147    Equal,
148    NotEqual,
149    Less,
150    LessOrEqual,
151    Greater,
152    GreaterOrEqual,
153}
154
155#[derive(Clone, Debug, PartialEq)]
156enum Operand {
157    /// A runtime expression, evaluated where the condition is decided.
158    Expression(String),
159    /// A literal written into the condition.
160    Literal(Value),
161}
162
163/// The runtime expressions a `simple` condition reads, found the way
164/// the condition itself is read.
165///
166/// Working this out by splitting on spaces misses
167/// `$statusCode==200&&$steps.b.outputs.ready`, where nothing separates
168/// the operands but the operators — and would find one inside a quoted
169/// literal, where there is none. The tokenizer already knows the
170/// difference, so it answers. A condition that does not tokenize names
171/// nothing; it will say so when it is evaluated.
172pub(crate) fn expressions_in(condition: &str) -> Vec<String> {
173    tokenize(condition).map_or_else(
174        |_| Vec::new(),
175        |tokens| {
176            tokens
177                .into_iter()
178                .filter_map(|token| match token {
179                    Token::Value(Operand::Expression(expression)) => Some(expression),
180                    _ => None,
181                })
182                .collect()
183        },
184    )
185}
186
187fn simple(condition: &str, scope: &Scope<'_>) -> Result<bool, CriterionError> {
188    let tokens = tokenize(condition)?;
189    let mut parser = Parser {
190        tokens: &tokens,
191        at: 0,
192        condition,
193        scope,
194    };
195    let holds = parser.disjunction()?;
196    if parser.at < parser.tokens.len() {
197        return Err(parser.error("unexpected trailing input"));
198    }
199    Ok(holds)
200}
201
202fn tokenize(condition: &str) -> Result<Vec<Token>, CriterionError> {
203    let syntax = |message: &str| CriterionError::Syntax {
204        condition: condition.to_owned(),
205        message: message.to_owned(),
206    };
207    let bytes: Vec<char> = condition.chars().collect();
208    let mut tokens = Vec::new();
209    let mut at = 0;
210    while at < bytes.len() {
211        let char = bytes[at];
212        match char {
213            char if char.is_whitespace() => at += 1,
214            '(' => {
215                tokens.push(Token::Open);
216                at += 1;
217            }
218            ')' => {
219                tokens.push(Token::Close);
220                at += 1;
221            }
222            '&' | '|' => {
223                let next = bytes.get(at + 1).copied();
224                if next != Some(char) {
225                    return Err(syntax(&format!("`{char}` must be doubled")));
226                }
227                tokens.push(if char == '&' { Token::And } else { Token::Or });
228                at += 2;
229            }
230            '=' | '!' | '<' | '>' => {
231                let doubled = bytes.get(at + 1) == Some(&'=');
232                let comparison = match (char, doubled) {
233                    ('=', true) => Comparison::Equal,
234                    ('!', true) => Comparison::NotEqual,
235                    ('<', true) => Comparison::LessOrEqual,
236                    ('>', true) => Comparison::GreaterOrEqual,
237                    ('<', false) => Comparison::Less,
238                    ('>', false) => Comparison::Greater,
239                    (char, _) => return Err(syntax(&format!("`{char}` must be followed by `=`"))),
240                };
241                at += if doubled { 2 } else { 1 };
242                tokens.push(Token::Compare(comparison));
243            }
244            '\'' | '"' => {
245                let quote = char;
246                let start = at + 1;
247                let mut end = start;
248                while end < bytes.len() && bytes[end] != quote {
249                    end += 1;
250                }
251                if end >= bytes.len() {
252                    return Err(syntax("a string is missing its closing quote"));
253                }
254                let text: String = bytes[start..end].iter().collect();
255                tokens.push(Token::Value(Operand::Literal(Value::String(text))));
256                at = end + 1;
257            }
258            _ => {
259                // A word: a runtime expression, a number, a keyword, or
260                // an unquoted string. It runs until whitespace, a
261                // bracket, or the start of an operator.
262                let start = at;
263                while at < bytes.len()
264                    && !bytes[at].is_whitespace()
265                    && !matches!(bytes[at], '(' | ')' | '&' | '|' | '=' | '!' | '<' | '>')
266                {
267                    at += 1;
268                }
269                let word: String = bytes[start..at].iter().collect();
270                if word.is_empty() {
271                    return Err(syntax("expected a value"));
272                }
273                tokens.push(Token::Value(operand(&word)));
274            }
275        }
276    }
277    if tokens.is_empty() {
278        return Err(syntax("the condition is empty"));
279    }
280    Ok(tokens)
281}
282
283fn operand(word: &str) -> Operand {
284    if expression::is_expression(word) {
285        return Operand::Expression(word.to_owned());
286    }
287    match word {
288        "true" => Operand::Literal(Value::Bool(true)),
289        "false" => Operand::Literal(Value::Bool(false)),
290        "null" => Operand::Literal(Value::Null),
291        _ => match word.parse::<f64>() {
292            Ok(number) => Operand::Literal(
293                serde_json::Number::from_f64(number).map_or(Value::Null, Value::Number),
294            ),
295            // The spec quotes its strings, but an unquoted word can only
296            // be one, so read it as written rather than refusing.
297            Err(_) => Operand::Literal(Value::String(word.to_owned())),
298        },
299    }
300}
301
302struct Parser<'p> {
303    tokens: &'p [Token],
304    at: usize,
305    condition: &'p str,
306    scope: &'p Scope<'p>,
307}
308
309impl Parser<'_> {
310    fn error(&self, message: &str) -> CriterionError {
311        CriterionError::Syntax {
312            condition: self.condition.to_owned(),
313            message: message.to_owned(),
314        }
315    }
316
317    fn peek(&self) -> Option<&Token> {
318        self.tokens.get(self.at)
319    }
320
321    /// `a || b` — the loosest binding, so the outermost.
322    fn disjunction(&mut self) -> Result<bool, CriterionError> {
323        let mut holds = self.conjunction()?;
324        while self.peek() == Some(&Token::Or) {
325            self.at += 1;
326            // Both sides are evaluated: a condition naming something
327            // absent should say so rather than depend on the order.
328            holds = self.conjunction()? || holds;
329        }
330        Ok(holds)
331    }
332
333    /// `a && b`.
334    fn conjunction(&mut self) -> Result<bool, CriterionError> {
335        let mut holds = self.comparison()?;
336        while self.peek() == Some(&Token::And) {
337            self.at += 1;
338            holds = self.comparison()? && holds;
339        }
340        Ok(holds)
341    }
342
343    /// `a == b`, or a lone operand read for its truth.
344    fn comparison(&mut self) -> Result<bool, CriterionError> {
345        if self.peek() == Some(&Token::Open) {
346            self.at += 1;
347            let holds = self.disjunction()?;
348            if self.peek() != Some(&Token::Close) {
349                return Err(self.error("a `(` is missing its `)`"));
350            }
351            self.at += 1;
352            return Ok(holds);
353        }
354        let left = self.operand()?;
355        let Some(&Token::Compare(comparison)) = self.peek() else {
356            return Ok(truthy(&left));
357        };
358        self.at += 1;
359        let right = self.operand()?;
360        Ok(holds(comparison, &left, &right))
361    }
362
363    fn operand(&mut self) -> Result<Value, CriterionError> {
364        match self.tokens.get(self.at) {
365            Some(Token::Value(operand)) => {
366                self.at += 1;
367                match operand {
368                    Operand::Literal(literal) => Ok(literal.clone()),
369                    Operand::Expression(expression) => {
370                        Ok(expression::evaluate(expression, self.scope)?)
371                    }
372                }
373            }
374            _ => Err(self.error("expected a value")),
375        }
376    }
377}
378
379fn holds(comparison: Comparison, left: &Value, right: &Value) -> bool {
380    let ordering = compare(left, right);
381    let equal = ordering == Some(Ordering::Equal) || left == right;
382    match comparison {
383        Comparison::Equal => equal,
384        Comparison::NotEqual => !equal,
385        Comparison::Less => ordering == Some(Ordering::Less),
386        Comparison::LessOrEqual => matches!(ordering, Some(Ordering::Less | Ordering::Equal)),
387        Comparison::Greater => ordering == Some(Ordering::Greater),
388        Comparison::GreaterOrEqual => matches!(ordering, Some(Ordering::Greater | Ordering::Equal)),
389    }
390}
391
392/// Order two values, when they are the sort of things that can be
393/// ordered. A number written as a string still compares as a number —
394/// a header carries `"200"` where a status code carries `200`, and a
395/// condition means the same thing by both.
396fn compare(left: &Value, right: &Value) -> Option<Ordering> {
397    match (left, right) {
398        (Value::Number(left), Value::Number(right)) => left.as_f64()?.partial_cmp(&right.as_f64()?),
399        // "String comparisons MUST be case insensitive" — so `PLACED`
400        // and `placed` are the same word to a condition.
401        (Value::String(left), Value::String(right)) => {
402            Some(left.to_lowercase().cmp(&right.to_lowercase()))
403        }
404        (Value::Bool(left), Value::Bool(right)) => Some(left.cmp(right)),
405        (Value::Number(number), Value::String(text))
406        | (Value::String(text), Value::Number(number)) => {
407            let text: f64 = text.parse().ok()?;
408            let number = number.as_f64()?;
409            if matches!(left, Value::Number(_)) {
410                number.partial_cmp(&text)
411            } else {
412                text.partial_cmp(&number)
413            }
414        }
415        _ => None,
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::expression::tests::{Fixture, exchange};
423    use roas_arazzo::v1_1::ExpressionType;
424    use serde_json::json;
425
426    fn criterion(
427        condition: &str,
428        context: Option<&str>,
429        type_: Option<CriterionType>,
430    ) -> Criterion {
431        Criterion {
432            context: context.map(ToOwned::to_owned),
433            condition: condition.to_owned(),
434            type_,
435            extensions: None,
436        }
437    }
438
439    fn decide(condition: &str) -> Result<bool, CriterionError> {
440        let fixture = Fixture {
441            here: Some(exchange()),
442            ..Fixture::default()
443        };
444        passes(&criterion(condition, None, None), &fixture.scope())
445    }
446
447    #[test]
448    fn a_status_code_is_compared_the_way_the_examples_write_it() {
449        assert_eq!(decide("$statusCode == 200"), Ok(true));
450        assert_eq!(decide("$statusCode != 200"), Ok(false));
451        assert_eq!(decide("$statusCode >= 200 && $statusCode < 300"), Ok(true));
452        assert_eq!(decide("$statusCode > 200"), Ok(false));
453        assert_eq!(decide("$statusCode <= 200"), Ok(true));
454    }
455
456    #[test]
457    fn strings_compare_quoted_either_way_and_unquoted() {
458        assert_eq!(decide("$response.body#/tags/0 == 'cat'"), Ok(true));
459        assert_eq!(decide(r#"$response.body#/tags/0 == "cat""#), Ok(true));
460        assert_eq!(decide("$response.body#/tags/0 == cat"), Ok(true));
461        assert_eq!(decide("$response.body#/tags/0 == 'dog'"), Ok(false));
462    }
463
464    #[test]
465    fn a_number_written_as_text_still_compares_as_a_number() {
466        assert_eq!(decide("$request.path.petId == 7"), Ok(true));
467        assert_eq!(decide("$request.path.petId < 8"), Ok(true));
468    }
469
470    #[test]
471    fn logic_groups_the_way_parentheses_say() {
472        assert_eq!(decide("$statusCode == 500 || $statusCode == 200"), Ok(true));
473        assert_eq!(
474            decide("($statusCode == 500 || $statusCode == 200) && $method == GET"),
475            Ok(true)
476        );
477        assert_eq!(
478            decide("$statusCode == 500 || ($statusCode == 200 && $method == POST)"),
479            Ok(false)
480        );
481        assert_eq!(decide("true && false"), Ok(false));
482        assert_eq!(decide("true || false"), Ok(true));
483    }
484
485    #[test]
486    fn an_operand_alone_is_read_for_its_truth() {
487        assert_eq!(
488            decide("$response.body#/tags"),
489            Ok(true),
490            "a non-empty array"
491        );
492        assert_eq!(
493            decide("$request.body#/name"),
494            Ok(true),
495            "a non-empty string"
496        );
497        assert_eq!(decide("false"), Ok(false));
498        assert_eq!(decide("null"), Ok(false));
499        assert_eq!(decide("0"), Ok(false));
500    }
501
502    #[test]
503    fn a_condition_that_does_not_parse_says_where() {
504        for (condition, expected) in [
505            ("", "the condition is empty"),
506            ("$statusCode ==", "expected a value"),
507            ("($statusCode == 200", "a `(` is missing its `)`"),
508            ("$statusCode & 1", "`&` must be doubled"),
509            ("'unclosed", "a string is missing its closing quote"),
510            ("$statusCode == 200)", "unexpected trailing input"),
511        ] {
512            let error = decide(condition).unwrap_err();
513            assert!(
514                error.to_string().contains(expected),
515                "`{condition}`: expected {expected:?}, got {error}"
516            );
517        }
518    }
519
520    #[test]
521    fn an_expression_that_names_nothing_is_an_error_not_a_false() {
522        assert!(matches!(
523            decide("$inputs.nope == 1"),
524            Err(CriterionError::Expression(_))
525        ));
526        // Both sides of `||` are evaluated, so a broken name is not
527        // hidden by a true on its left.
528        assert!(matches!(
529            decide("$statusCode == 200 || $inputs.nope == 1"),
530            Err(CriterionError::Expression(_))
531        ));
532    }
533
534    #[test]
535    fn a_regex_criterion_matches_the_context() {
536        let fixture = Fixture {
537            here: Some(exchange()),
538            ..Fixture::default()
539        };
540        let regex = |condition| {
541            passes(
542                &criterion(
543                    condition,
544                    Some("$response.body#/tags/0"),
545                    Some(CriterionType::Simple(CriterionKind::Regex)),
546                ),
547                &fixture.scope(),
548            )
549        };
550        assert_eq!(regex("^c.t$"), Ok(true));
551        assert_eq!(regex("^dog$"), Ok(false));
552        assert!(matches!(regex("["), Err(CriterionError::Regex { .. })));
553    }
554
555    #[test]
556    fn a_jsonpath_criterion_asks_whether_anything_matches() {
557        let fixture = Fixture {
558            here: Some(exchange()),
559            ..Fixture::default()
560        };
561        let path = |condition| {
562            passes(
563                &criterion(
564                    condition,
565                    Some("$response.body"),
566                    Some(CriterionType::Simple(CriterionKind::Jsonpath)),
567                ),
568                &fixture.scope(),
569            )
570        };
571        assert_eq!(path("$.id"), Ok(true));
572        assert_eq!(path("$.nope"), Ok(false));
573        assert_eq!(path("$.tags[*]"), Ok(true));
574        // A filter picks members, so this asks whether any member of the
575        // body is the number 7 — `id` is.
576        assert_eq!(path("$[?@ == 7]"), Ok(true));
577        assert_eq!(path("$[?@ == 8]"), Ok(false));
578    }
579
580    #[test]
581    fn a_typed_criterion_without_a_context_says_so() {
582        let fixture = Fixture::default();
583        assert_eq!(
584            passes(
585                &criterion(
586                    "^x$",
587                    None,
588                    Some(CriterionType::Simple(CriterionKind::Regex))
589                ),
590                &fixture.scope()
591            ),
592            Err(CriterionError::MissingContext("regex"))
593        );
594    }
595
596    #[test]
597    fn an_expression_typed_criterion_names_its_language() {
598        let fixture = Fixture {
599            here: Some(exchange()),
600            ..Fixture::default()
601        };
602        let typed = |kind, condition| {
603            passes(
604                &criterion(
605                    condition,
606                    Some("$response.body"),
607                    Some(CriterionType::Expression(ExpressionType {
608                        type_: kind,
609                        version: String::new(),
610                        extensions: None,
611                    })),
612                ),
613                &fixture.scope(),
614            )
615        };
616        assert_eq!(typed(ExpressionKind::Jsonpath, "$.id"), Ok(true));
617        assert_eq!(typed(ExpressionKind::Jsonpointer, "/id"), Ok(true));
618        assert_eq!(typed(ExpressionKind::Jsonpointer, "/nope"), Ok(false));
619        assert_eq!(
620            typed(ExpressionKind::Xpath, "/id"),
621            Err(CriterionError::Unsupported("XPath"))
622        );
623    }
624
625    #[test]
626    fn xpath_says_it_is_not_supported() {
627        let fixture = Fixture::default();
628        assert_eq!(
629            passes(
630                &criterion(
631                    "/x",
632                    Some("$inputs"),
633                    Some(CriterionType::Simple(CriterionKind::Xpath))
634                ),
635                &fixture.scope()
636            ),
637            Err(CriterionError::Unsupported("XPath"))
638        );
639    }
640
641    #[test]
642    fn values_that_cannot_be_ordered_are_only_ever_equal_or_not() {
643        assert!(holds(Comparison::Equal, &json!({"a": 1}), &json!({"a": 1})));
644        assert!(holds(
645            Comparison::NotEqual,
646            &json!({"a": 1}),
647            &json!({"a": 2})
648        ));
649        assert!(!holds(Comparison::Less, &json!({"a": 1}), &json!({"a": 2})));
650        assert_eq!(compare(&json!(null), &json!(1)), None);
651    }
652}