1use 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#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
24pub enum CriterionError {
25 #[error(transparent)]
27 Expression(#[from] ExpressionError),
28 #[error(transparent)]
30 Select(#[from] SelectError),
31 #[error("`{condition}` is not a valid condition: {message}")]
33 Syntax {
34 condition: String,
36 message: String,
38 },
39 #[error("`{condition}` is not a valid regular expression: {message}")]
41 Regex {
42 condition: String,
44 message: String,
46 },
47 #[error("a `{0}` criterion needs a `context`")]
49 MissingContext(&'static str),
50 #[error("{0} criteria are not supported by this executor")]
52 Unsupported(&'static str),
53}
54
55pub(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 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
94fn 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
112fn text(value: &Value) -> String {
115 match value {
116 Value::String(text) => text.clone(),
117 other => other.to_string(),
118 }
119}
120
121fn 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#[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 Expression(String),
159 Literal(Value),
161}
162
163pub(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 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 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 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 holds = self.conjunction()? || holds;
329 }
330 Ok(holds)
331 }
332
333 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 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
392fn 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 (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 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 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}