use std::fmt;
use camel_component_api::Body;
use regex::Regex;
use serde_json::Value;
use crate::assert::body_eq;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum BodyMatcher {
Equals(Body),
Regex(String),
Contains(String),
StartsWith(String),
EndsWith(String),
Exists,
JsonSubset(Value),
}
impl BodyMatcher {
pub fn matches(&self, actual: &Body) -> bool {
match self {
BodyMatcher::Equals(expected) => body_eq(expected, actual),
BodyMatcher::Regex(pattern) => match actual {
Body::Text(text) => compile(pattern).is_some_and(|re| re.is_match(text)),
_ => false,
},
BodyMatcher::Contains(needle) => match actual {
Body::Text(text) => text.contains(needle),
_ => false,
},
BodyMatcher::StartsWith(prefix) => match actual {
Body::Text(text) => text.starts_with(prefix),
_ => false,
},
BodyMatcher::EndsWith(suffix) => match actual {
Body::Text(text) => text.ends_with(suffix),
_ => false,
},
BodyMatcher::Exists => !matches!(actual, Body::Empty),
BodyMatcher::JsonSubset(pattern) => {
pattern.is_object()
&& json_value(actual).is_some_and(|received| json_subset(pattern, &received))
}
}
}
pub fn regex_pattern(&self) -> Option<&str> {
match self {
BodyMatcher::Regex(pattern) => Some(pattern),
_ => None,
}
}
pub fn mismatch_note(&self, actual: &Body) -> Option<&'static str> {
match self {
BodyMatcher::Regex(_)
| BodyMatcher::Contains(_)
| BodyMatcher::StartsWith(_)
| BodyMatcher::EndsWith(_) => match actual {
Body::Text(_) => None,
_ => Some("body is not text"),
},
BodyMatcher::JsonSubset(pattern) => {
if !pattern.is_object() {
return Some("body is not JSON");
}
match json_value(actual) {
None => Some("body is not JSON"),
Some(received) => {
if received.is_object() {
None
} else {
Some("body is not a JSON object")
}
}
}
}
BodyMatcher::Equals(_) | BodyMatcher::Exists => None,
}
}
}
impl fmt::Display for BodyMatcher {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BodyMatcher::Equals(v) => write!(f, "equals {}", compact_body(v)),
BodyMatcher::Regex(p) => write!(f, "regex {p}"),
BodyMatcher::Contains(n) => write!(f, "contains {n}"),
BodyMatcher::StartsWith(p) => write!(f, "startsWith {p}"),
BodyMatcher::EndsWith(s) => write!(f, "endsWith {s}"),
BodyMatcher::Exists => write!(f, "exists"),
BodyMatcher::JsonSubset(v) => write!(f, "jsonSubset {}", compact(v)),
}
}
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum HeaderMatcher {
Equals(Value),
Regex(String),
Exists,
}
impl HeaderMatcher {
pub fn matches(&self, actual: Option<&Value>) -> bool {
match self {
HeaderMatcher::Exists => actual.is_some(),
HeaderMatcher::Equals(expected) => match actual {
Some(a) => a == expected,
None => false,
},
HeaderMatcher::Regex(pattern) => match actual {
Some(Value::String(s)) => compile(pattern).is_some_and(|re| re.is_match(s)),
_ => false,
},
}
}
pub fn regex_pattern(&self) -> Option<&str> {
match self {
HeaderMatcher::Regex(pattern) => Some(pattern),
_ => None,
}
}
pub fn mismatch_note(&self, actual: Option<&Value>) -> Option<&'static str> {
match self {
HeaderMatcher::Regex(_) => match actual {
Some(Value::String(_)) => None,
_ => Some("value is not a string"),
},
HeaderMatcher::Equals(_) | HeaderMatcher::Exists => None,
}
}
}
impl fmt::Display for HeaderMatcher {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HeaderMatcher::Equals(v) => write!(f, "equals {}", compact(v)),
HeaderMatcher::Regex(p) => write!(f, "regex {p}"),
HeaderMatcher::Exists => write!(f, "exists"),
}
}
}
fn compile(pattern: &str) -> Option<Regex> {
Regex::new(pattern).ok()
}
fn compact(v: &Value) -> String {
serde_json::to_string(v).unwrap_or_else(|_| String::new())
}
pub(crate) fn compact_body(body: &Body) -> String {
match body {
Body::Json(v) => compact(v),
Body::Text(s) => s.clone(),
other => format!("{other:?}"),
}
}
fn json_value(body: &Body) -> Option<Value> {
match body {
Body::Json(v) => Some(v.clone()),
Body::Text(text) => serde_json::from_str(text).ok(),
_ => None,
}
}
fn json_subset(pattern: &Value, received: &Value) -> bool {
match (pattern, received) {
(Value::Object(p), Value::Object(r)) => p
.iter()
.all(|(key, pv)| r.get(key).is_some_and(|rv| json_subset(pv, rv))),
(Value::Array(p), Value::Array(r)) => {
p.len() == r.len() && p.iter().zip(r.iter()).all(|(a, b)| a == b)
}
_ => pattern == received,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn regex_body_pass_and_fail() {
assert!(
BodyMatcher::Regex("^order-[0-9]+$".into()).matches(&Body::Text("order-42".into()))
);
assert!(
!BodyMatcher::Regex("^order-[0-9]+$".into()).matches(&Body::Text("refunded-42".into()))
);
}
#[test]
fn substring_and_anchor_matchers() {
let body = Body::Text("order-total-42".into());
assert!(BodyMatcher::Contains("total".into()).matches(&body));
assert!(BodyMatcher::StartsWith("order-".into()).matches(&body));
assert!(BodyMatcher::EndsWith("-42".into()).matches(&body));
}
#[test]
fn exists_body_variants() {
assert!(BodyMatcher::Exists.matches(&Body::Text("x".into())));
assert!(!BodyMatcher::Exists.matches(&Body::Empty));
}
#[test]
fn string_matchers_fail_non_text() {
let json_body = Body::Json(json!({"a": 1}));
let bytes_body = Body::Bytes(vec![97u8].into());
assert!(!BodyMatcher::Contains("a".into()).matches(&json_body));
assert!(!BodyMatcher::Contains("a".into()).matches(&bytes_body));
assert_eq!(
BodyMatcher::Contains("a".into()).mismatch_note(&json_body),
Some("body is not text")
);
assert_eq!(
BodyMatcher::Contains("a".into()).mismatch_note(&bytes_body),
Some("body is not text")
);
}
#[test]
fn json_subset_recursive_ignores_extra() {
let matcher = BodyMatcher::JsonSubset(json!({"status": "ok", "meta": {"seq": 3}}));
let body = Body::Json(json!({"id": 7, "status": "ok", "meta": {"seq": 3, "ts": 9}}));
assert!(matcher.matches(&body));
}
#[test]
fn json_subset_arrays_exact() {
let matcher = BodyMatcher::JsonSubset(json!({"tags": ["a", "b"]}));
assert!(!matcher.matches(&Body::Json(json!({"tags": ["b", "a"]}))));
assert!(matcher.matches(&Body::Json(json!({"tags": ["a", "b"]}))));
}
#[test]
fn json_subset_parses_text() {
let matcher = BodyMatcher::JsonSubset(json!({"status": "ok"}));
assert!(matcher.matches(&Body::Text("{\"status\": \"ok\"}".into())));
let bad = Body::Text("ok".into());
assert!(!matcher.matches(&bad));
assert_eq!(matcher.mismatch_note(&bad), Some("body is not JSON"));
assert!(!BodyMatcher::JsonSubset(json!(null)).matches(&Body::Json(json!(null))));
assert!(!BodyMatcher::JsonSubset(json!([1, 2])).matches(&Body::Json(json!([1, 2]))));
}
#[test]
fn json_subset_null_requires_null() {
let matcher = BodyMatcher::JsonSubset(json!({"err": null}));
assert!(matcher.matches(&Body::Json(json!({"err": null}))));
assert!(!matcher.matches(&Body::Json(json!({"err": 0}))));
}
#[test]
fn header_null_and_missing() {
assert!(HeaderMatcher::Exists.matches(Some(&Value::Null)));
assert!(!HeaderMatcher::Exists.matches(None));
assert!(HeaderMatcher::Equals(Value::Null).matches(Some(&Value::Null)));
let regex = HeaderMatcher::Regex("^a$".into());
assert!(!regex.matches(Some(&Value::Null)));
assert_eq!(
regex.mismatch_note(Some(&Value::Null)),
Some("value is not a string")
);
}
}