use crate::validation::{Context, ValidateWithContext};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum CriterionType {
#[default]
Simple,
Regex,
Jsonpath,
Xpath,
}
const JSONPATH_VERSION: &str = "draft-goessner-dispatch-jsonpath-00";
const XPATH_VERSIONS: [&str; 3] = ["xpath-10", "xpath-20", "xpath-30"];
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Default)]
pub struct Criterion {
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
pub condition: String,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_: Option<CriterionType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(flatten)]
#[serde(with = "crate::common::extensions")]
#[serde(skip_serializing_if = "Option::is_none")]
pub extensions: Option<BTreeMap<String, serde_json::Value>>,
}
impl ValidateWithContext for Criterion {
fn validate_with_context(&self, ctx: &mut Context) {
ctx.require_non_empty("condition", &self.condition);
if self.type_.is_some() && self.context.is_none() {
ctx.error_field("context", "is required when `type` is set");
}
if let Some(version) = &self.version {
match self.type_ {
Some(CriterionType::Jsonpath) if version != JSONPATH_VERSION => {
ctx.error_field(
"version",
format!("must be `{JSONPATH_VERSION}` for type `jsonpath`"),
);
}
Some(CriterionType::Xpath) if !XPATH_VERSIONS.contains(&version.as_str()) => {
ctx.error_field(
"version",
"must be one of `xpath-10`, `xpath-20`, `xpath-30` for type `xpath`",
);
}
Some(CriterionType::Jsonpath | CriterionType::Xpath) => {}
_ => ctx.error_field("version", "is only valid with type `jsonpath` or `xpath`"),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use enumset::EnumSet;
use serde_json::json;
fn validate(c: &Criterion) -> Vec<String> {
let mut ctx = Context::with_path(EnumSet::empty(), "#.c");
c.validate_with_context(&mut ctx);
ctx.errors.iter().map(ToString::to_string).collect()
}
#[test]
fn simple_condition_round_trips() {
let c: Criterion =
serde_json::from_value(json!({ "condition": "$statusCode == 200" })).unwrap();
assert_eq!(c.condition, "$statusCode == 200");
assert!(c.type_.is_none());
assert!(validate(&c).is_empty());
}
#[test]
fn flat_expression_type_round_trips() {
let c: Criterion = serde_json::from_value(json!({
"context": "$response.body",
"condition": "$[?count(@.pets) > 0]",
"type": "jsonpath",
"version": "draft-goessner-dispatch-jsonpath-00",
}))
.unwrap();
assert_eq!(c.type_, Some(CriterionType::Jsonpath));
assert_eq!(c.version.as_deref(), Some(JSONPATH_VERSION));
assert!(validate(&c).is_empty());
}
#[test]
fn empty_condition_is_rejected() {
let c = Criterion::default();
assert!(validate(&c).iter().any(|e| e.contains("condition")));
}
#[test]
fn type_without_context_is_rejected() {
let c = Criterion {
condition: "x".into(),
type_: Some(CriterionType::Regex),
..Default::default()
};
assert!(
validate(&c)
.iter()
.any(|e| e == "#.c.context: is required when `type` is set")
);
}
#[test]
fn jsonpath_with_wrong_version_is_rejected() {
let c = Criterion {
context: Some("$x".into()),
condition: "x".into(),
type_: Some(CriterionType::Jsonpath),
version: Some("nope".into()),
..Default::default()
};
assert!(validate(&c).iter().any(|e| e.contains("jsonpath")));
}
#[test]
fn xpath_version_must_be_in_set() {
let bad = Criterion {
context: Some("$x".into()),
condition: "x".into(),
type_: Some(CriterionType::Xpath),
version: Some("xpath-99".into()),
..Default::default()
};
assert!(validate(&bad).iter().any(|e| e.contains("xpath")));
let ok = Criterion {
version: Some("xpath-30".into()),
..bad
};
assert!(validate(&ok).is_empty());
}
#[test]
fn version_without_expression_type_is_rejected() {
let c = Criterion {
context: Some("$x".into()),
condition: "x".into(),
type_: Some(CriterionType::Simple),
version: Some("xpath-10".into()),
..Default::default()
};
assert!(validate(&c).iter().any(|e| e.contains("only valid with")));
}
}