1use std::fmt;
9
10use camel_component_api::Body;
11use camel_matchers::{Expectation, expectation_matches};
12use regex::Regex;
13use serde_json::Value;
14
15use crate::assert::body_eq;
16
17#[derive(Clone, Debug)]
19#[non_exhaustive]
20pub enum BodyMatcher {
21 Equals(Body),
23 Regex(String),
25 Contains(String),
27 StartsWith(String),
29 EndsWith(String),
31 Exists,
33 JsonSubset(Value),
35}
36
37impl BodyMatcher {
38 pub fn matches(&self, actual: &Body) -> bool {
40 match self {
41 BodyMatcher::Equals(expected) => body_eq(expected, actual),
42 BodyMatcher::Regex(pattern) => text_only(actual).is_some_and(|value| {
43 expectation_matches(&Expectation::Regex(pattern.clone()), &value)
44 }),
45 BodyMatcher::Contains(needle) => text_only(actual).is_some_and(|value| {
46 expectation_matches(&Expectation::Contains(needle.clone()), &value)
47 }),
48 BodyMatcher::StartsWith(prefix) => text_only(actual).is_some_and(|value| {
49 expectation_matches(&Expectation::StartsWith(prefix.clone()), &value)
50 }),
51 BodyMatcher::EndsWith(suffix) => text_only(actual).is_some_and(|value| {
52 expectation_matches(&Expectation::EndsWith(suffix.clone()), &value)
53 }),
54 BodyMatcher::Exists => !matches!(actual, Body::Empty),
55 BodyMatcher::JsonSubset(pattern) => {
56 pattern.is_object()
57 && json_value(actual).is_some_and(|received| {
58 expectation_matches(&Expectation::JsonSubset(pattern.clone()), &received)
59 })
60 }
61 }
62 }
63
64 pub fn regex_pattern(&self) -> Option<&str> {
66 match self {
67 BodyMatcher::Regex(pattern) => Some(pattern),
68 _ => None,
69 }
70 }
71
72 pub fn mismatch_note(&self, actual: &Body) -> Option<&'static str> {
75 match self {
76 BodyMatcher::Regex(_)
77 | BodyMatcher::Contains(_)
78 | BodyMatcher::StartsWith(_)
79 | BodyMatcher::EndsWith(_) => match actual {
80 Body::Text(_) => None,
81 _ => Some("body is not text"),
82 },
83 BodyMatcher::JsonSubset(pattern) => {
84 if !pattern.is_object() {
85 return Some("body is not JSON");
86 }
87 match json_value(actual) {
88 None => Some("body is not JSON"),
89 Some(received) => {
90 if received.is_object() {
91 None
92 } else {
93 Some("body is not a JSON object")
94 }
95 }
96 }
97 }
98 BodyMatcher::Equals(_) | BodyMatcher::Exists => None,
99 }
100 }
101}
102
103impl fmt::Display for BodyMatcher {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 match self {
106 BodyMatcher::Equals(v) => write!(f, "equals {}", compact_body(v)),
107 BodyMatcher::Regex(p) => write!(f, "regex {p}"),
108 BodyMatcher::Contains(n) => write!(f, "contains {n}"),
109 BodyMatcher::StartsWith(p) => write!(f, "startsWith {p}"),
110 BodyMatcher::EndsWith(s) => write!(f, "endsWith {s}"),
111 BodyMatcher::Exists => write!(f, "exists"),
112 BodyMatcher::JsonSubset(v) => write!(f, "jsonSubset {}", compact(v)),
113 }
114 }
115}
116
117#[derive(Clone, Debug)]
119#[non_exhaustive]
120pub enum HeaderMatcher {
121 Equals(Value),
123 Regex(String),
125 Exists,
127}
128
129impl HeaderMatcher {
130 pub fn matches(&self, actual: Option<&Value>) -> bool {
132 match self {
133 HeaderMatcher::Exists => actual.is_some(),
134 HeaderMatcher::Equals(expected) => match actual {
135 Some(a) => a == expected,
136 None => false,
137 },
138 HeaderMatcher::Regex(pattern) => match actual {
139 Some(Value::String(s)) => compile(pattern).is_some_and(|re| re.is_match(s)),
140 _ => false,
141 },
142 }
143 }
144
145 pub fn regex_pattern(&self) -> Option<&str> {
147 match self {
148 HeaderMatcher::Regex(pattern) => Some(pattern),
149 _ => None,
150 }
151 }
152
153 pub fn mismatch_note(&self, actual: Option<&Value>) -> Option<&'static str> {
156 match self {
157 HeaderMatcher::Regex(_) => match actual {
158 Some(Value::String(_)) => None,
159 _ => Some("value is not a string"),
160 },
161 HeaderMatcher::Equals(_) | HeaderMatcher::Exists => None,
162 }
163 }
164}
165
166impl fmt::Display for HeaderMatcher {
167 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168 match self {
169 HeaderMatcher::Equals(v) => write!(f, "equals {}", compact(v)),
170 HeaderMatcher::Regex(p) => write!(f, "regex {p}"),
171 HeaderMatcher::Exists => write!(f, "exists"),
172 }
173 }
174}
175
176fn compile(pattern: &str) -> Option<Regex> {
178 Regex::new(pattern).ok()
179}
180
181fn compact(v: &Value) -> String {
183 serde_json::to_string(v).unwrap_or_else(|_| String::new())
184}
185
186pub(crate) fn compact_body(body: &Body) -> String {
188 match body {
189 Body::Json(v) => compact(v),
190 Body::Text(s) => s.clone(),
191 other => format!("{other:?}"),
192 }
193}
194
195fn text_only(body: &Body) -> Option<Value> {
199 match body {
200 Body::Text(text) => Some(Value::String(text.clone())),
201 _ => None,
202 }
203}
204
205fn json_value(body: &Body) -> Option<Value> {
207 match body {
208 Body::Json(v) => Some(v.clone()),
209 Body::Text(text) => serde_json::from_str(text).ok(),
210 _ => None,
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use serde_json::json;
218
219 #[test]
220 fn regex_body_pass_and_fail() {
221 assert!(
222 BodyMatcher::Regex("^order-[0-9]+$".into()).matches(&Body::Text("order-42".into()))
223 );
224 assert!(
225 !BodyMatcher::Regex("^order-[0-9]+$".into()).matches(&Body::Text("refunded-42".into()))
226 );
227 }
228
229 #[test]
230 fn substring_and_anchor_matchers() {
231 let body = Body::Text("order-total-42".into());
232 assert!(BodyMatcher::Contains("total".into()).matches(&body));
233 assert!(BodyMatcher::StartsWith("order-".into()).matches(&body));
234 assert!(BodyMatcher::EndsWith("-42".into()).matches(&body));
235 }
236
237 #[test]
238 fn exists_body_variants() {
239 assert!(BodyMatcher::Exists.matches(&Body::Text("x".into())));
240 assert!(!BodyMatcher::Exists.matches(&Body::Empty));
241 }
242
243 #[test]
244 fn string_matchers_fail_non_text() {
245 let json_body = Body::Json(json!({"a": 1}));
246 let bytes_body = Body::Bytes(vec![97u8].into());
247 assert!(!BodyMatcher::Contains("a".into()).matches(&json_body));
248 assert!(!BodyMatcher::Contains("a".into()).matches(&bytes_body));
249 assert_eq!(
250 BodyMatcher::Contains("a".into()).mismatch_note(&json_body),
251 Some("body is not text")
252 );
253 assert_eq!(
254 BodyMatcher::Contains("a".into()).mismatch_note(&bytes_body),
255 Some("body is not text")
256 );
257 }
258
259 #[test]
260 fn string_verbs_delegate_through_text_projection() {
261 let pattern = "^order-[0-9]+$".to_string();
262 let body = Body::Text("order-42".into());
263 assert!(BodyMatcher::Regex(pattern.clone()).matches(&body));
264 assert_eq!(
268 BodyMatcher::Regex(pattern.clone()).matches(&body),
269 expectation_matches(
270 &Expectation::Regex(pattern.clone()),
271 &Value::String("order-42".into())
272 )
273 );
274 let json_body = Body::Json(json!({"total": 42}));
277 assert!(!BodyMatcher::Contains("total".into()).matches(&json_body));
278 }
279
280 #[test]
281 fn non_text_bodies_fail_closed_for_string_verbs() {
282 let matchers = [
283 BodyMatcher::Regex("x".into()),
284 BodyMatcher::Contains("x".into()),
285 BodyMatcher::StartsWith("x".into()),
286 BodyMatcher::EndsWith("x".into()),
287 ];
288 let json_body = Body::Json(json!({"x": 1}));
289 let bytes_body = Body::Bytes(vec![120u8].into());
290 for matcher in &matchers {
291 assert!(!matcher.matches(&json_body), "{matcher:?} over json");
292 assert!(!matcher.matches(&bytes_body), "{matcher:?} over bytes");
293 assert!(!matcher.matches(&Body::Empty), "{matcher:?} over empty");
294 assert_eq!(matcher.mismatch_note(&json_body), Some("body is not text"));
295 assert_eq!(matcher.mismatch_note(&bytes_body), Some("body is not text"));
296 }
297 }
298
299 #[test]
300 fn json_subset_local_duplicate_deleted() {
301 let source = include_str!("matcher.rs");
310 let needle = concat!("fn json_", "subset(pattern");
311 assert!(!source.contains(needle));
312 }
313
314 #[test]
315 fn json_subset_delegation_preserves_verdicts() {
316 let matcher = BodyMatcher::JsonSubset(json!({"status": "ok", "meta": {"seq": 3}}));
317 let superset = Body::Json(json!({"id": 7, "status": "ok", "meta": {"seq": 3, "ts": 9}}));
318 assert!(matcher.matches(&superset));
319 let mismatched = Body::Json(json!({"status": "ok", "meta": {"seq": 4}}));
320 assert!(!matcher.matches(&mismatched));
321 assert!(!BodyMatcher::JsonSubset(json!(5)).matches(&Body::Json(json!(5))));
324 assert!(!BodyMatcher::JsonSubset(json!(5)).matches(&Body::Text("5".into())));
325 }
326
327 #[test]
328 fn json_subset_recursive_ignores_extra() {
329 let matcher = BodyMatcher::JsonSubset(json!({"status": "ok", "meta": {"seq": 3}}));
330 let body = Body::Json(json!({"id": 7, "status": "ok", "meta": {"seq": 3, "ts": 9}}));
331 assert!(matcher.matches(&body));
332 }
333
334 #[test]
335 fn json_subset_arrays_exact() {
336 let matcher = BodyMatcher::JsonSubset(json!({"tags": ["a", "b"]}));
337 assert!(!matcher.matches(&Body::Json(json!({"tags": ["b", "a"]}))));
338 assert!(matcher.matches(&Body::Json(json!({"tags": ["a", "b"]}))));
339 }
340
341 #[test]
342 fn json_subset_parses_text() {
343 let matcher = BodyMatcher::JsonSubset(json!({"status": "ok"}));
344 assert!(matcher.matches(&Body::Text("{\"status\": \"ok\"}".into())));
345 let bad = Body::Text("ok".into());
346 assert!(!matcher.matches(&bad));
347 assert_eq!(matcher.mismatch_note(&bad), Some("body is not JSON"));
348 assert!(!BodyMatcher::JsonSubset(json!(null)).matches(&Body::Json(json!(null))));
349 assert!(!BodyMatcher::JsonSubset(json!([1, 2])).matches(&Body::Json(json!([1, 2]))));
350 }
351
352 #[test]
353 fn json_subset_null_requires_null() {
354 let matcher = BodyMatcher::JsonSubset(json!({"err": null}));
355 assert!(matcher.matches(&Body::Json(json!({"err": null}))));
356 assert!(!matcher.matches(&Body::Json(json!({"err": 0}))));
357 }
358
359 #[test]
360 fn header_null_and_missing() {
361 assert!(HeaderMatcher::Exists.matches(Some(&Value::Null)));
362 assert!(!HeaderMatcher::Exists.matches(None));
363 assert!(HeaderMatcher::Equals(Value::Null).matches(Some(&Value::Null)));
364 let regex = HeaderMatcher::Regex("^a$".into());
365 assert!(!regex.matches(Some(&Value::Null)));
366 assert_eq!(
367 regex.mismatch_note(Some(&Value::Null)),
368 Some("value is not a string")
369 );
370 }
371}