1use std::fmt;
9
10use camel_component_api::Body;
11use regex::Regex;
12use serde_json::Value;
13
14use crate::assert::body_eq;
15
16#[derive(Clone, Debug)]
18#[non_exhaustive]
19pub enum BodyMatcher {
20 Equals(Body),
22 Regex(String),
24 Contains(String),
26 StartsWith(String),
28 EndsWith(String),
30 Exists,
32 JsonSubset(Value),
34}
35
36impl BodyMatcher {
37 pub fn matches(&self, actual: &Body) -> bool {
39 match self {
40 BodyMatcher::Equals(expected) => body_eq(expected, actual),
41 BodyMatcher::Regex(pattern) => match actual {
42 Body::Text(text) => compile(pattern).is_some_and(|re| re.is_match(text)),
43 _ => false,
44 },
45 BodyMatcher::Contains(needle) => match actual {
46 Body::Text(text) => text.contains(needle),
47 _ => false,
48 },
49 BodyMatcher::StartsWith(prefix) => match actual {
50 Body::Text(text) => text.starts_with(prefix),
51 _ => false,
52 },
53 BodyMatcher::EndsWith(suffix) => match actual {
54 Body::Text(text) => text.ends_with(suffix),
55 _ => false,
56 },
57 BodyMatcher::Exists => !matches!(actual, Body::Empty),
58 BodyMatcher::JsonSubset(pattern) => {
59 pattern.is_object()
60 && json_value(actual).is_some_and(|received| json_subset(pattern, &received))
61 }
62 }
63 }
64
65 pub fn regex_pattern(&self) -> Option<&str> {
67 match self {
68 BodyMatcher::Regex(pattern) => Some(pattern),
69 _ => None,
70 }
71 }
72
73 pub fn mismatch_note(&self, actual: &Body) -> Option<&'static str> {
76 match self {
77 BodyMatcher::Regex(_)
78 | BodyMatcher::Contains(_)
79 | BodyMatcher::StartsWith(_)
80 | BodyMatcher::EndsWith(_) => match actual {
81 Body::Text(_) => None,
82 _ => Some("body is not text"),
83 },
84 BodyMatcher::JsonSubset(pattern) => {
85 if !pattern.is_object() {
86 return Some("body is not JSON");
87 }
88 match json_value(actual) {
89 None => Some("body is not JSON"),
90 Some(received) => {
91 if received.is_object() {
92 None
93 } else {
94 Some("body is not a JSON object")
95 }
96 }
97 }
98 }
99 BodyMatcher::Equals(_) | BodyMatcher::Exists => None,
100 }
101 }
102}
103
104impl fmt::Display for BodyMatcher {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 match self {
107 BodyMatcher::Equals(v) => write!(f, "equals {}", compact_body(v)),
108 BodyMatcher::Regex(p) => write!(f, "regex {p}"),
109 BodyMatcher::Contains(n) => write!(f, "contains {n}"),
110 BodyMatcher::StartsWith(p) => write!(f, "startsWith {p}"),
111 BodyMatcher::EndsWith(s) => write!(f, "endsWith {s}"),
112 BodyMatcher::Exists => write!(f, "exists"),
113 BodyMatcher::JsonSubset(v) => write!(f, "jsonSubset {}", compact(v)),
114 }
115 }
116}
117
118#[derive(Clone, Debug)]
120#[non_exhaustive]
121pub enum HeaderMatcher {
122 Equals(Value),
124 Regex(String),
126 Exists,
128}
129
130impl HeaderMatcher {
131 pub fn matches(&self, actual: Option<&Value>) -> bool {
133 match self {
134 HeaderMatcher::Exists => actual.is_some(),
135 HeaderMatcher::Equals(expected) => match actual {
136 Some(a) => a == expected,
137 None => false,
138 },
139 HeaderMatcher::Regex(pattern) => match actual {
140 Some(Value::String(s)) => compile(pattern).is_some_and(|re| re.is_match(s)),
141 _ => false,
142 },
143 }
144 }
145
146 pub fn regex_pattern(&self) -> Option<&str> {
148 match self {
149 HeaderMatcher::Regex(pattern) => Some(pattern),
150 _ => None,
151 }
152 }
153
154 pub fn mismatch_note(&self, actual: Option<&Value>) -> Option<&'static str> {
157 match self {
158 HeaderMatcher::Regex(_) => match actual {
159 Some(Value::String(_)) => None,
160 _ => Some("value is not a string"),
161 },
162 HeaderMatcher::Equals(_) | HeaderMatcher::Exists => None,
163 }
164 }
165}
166
167impl fmt::Display for HeaderMatcher {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 match self {
170 HeaderMatcher::Equals(v) => write!(f, "equals {}", compact(v)),
171 HeaderMatcher::Regex(p) => write!(f, "regex {p}"),
172 HeaderMatcher::Exists => write!(f, "exists"),
173 }
174 }
175}
176
177fn compile(pattern: &str) -> Option<Regex> {
179 Regex::new(pattern).ok()
180}
181
182fn compact(v: &Value) -> String {
184 serde_json::to_string(v).unwrap_or_else(|_| String::new())
185}
186
187pub(crate) fn compact_body(body: &Body) -> String {
189 match body {
190 Body::Json(v) => compact(v),
191 Body::Text(s) => s.clone(),
192 other => format!("{other:?}"),
193 }
194}
195
196fn json_value(body: &Body) -> Option<Value> {
198 match body {
199 Body::Json(v) => Some(v.clone()),
200 Body::Text(text) => serde_json::from_str(text).ok(),
201 _ => None,
202 }
203}
204
205fn json_subset(pattern: &Value, received: &Value) -> bool {
208 match (pattern, received) {
209 (Value::Object(p), Value::Object(r)) => p
210 .iter()
211 .all(|(key, pv)| r.get(key).is_some_and(|rv| json_subset(pv, rv))),
212 (Value::Array(p), Value::Array(r)) => {
213 p.len() == r.len() && p.iter().zip(r.iter()).all(|(a, b)| a == b)
214 }
215 _ => pattern == received,
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use serde_json::json;
223
224 #[test]
225 fn regex_body_pass_and_fail() {
226 assert!(
227 BodyMatcher::Regex("^order-[0-9]+$".into()).matches(&Body::Text("order-42".into()))
228 );
229 assert!(
230 !BodyMatcher::Regex("^order-[0-9]+$".into()).matches(&Body::Text("refunded-42".into()))
231 );
232 }
233
234 #[test]
235 fn substring_and_anchor_matchers() {
236 let body = Body::Text("order-total-42".into());
237 assert!(BodyMatcher::Contains("total".into()).matches(&body));
238 assert!(BodyMatcher::StartsWith("order-".into()).matches(&body));
239 assert!(BodyMatcher::EndsWith("-42".into()).matches(&body));
240 }
241
242 #[test]
243 fn exists_body_variants() {
244 assert!(BodyMatcher::Exists.matches(&Body::Text("x".into())));
245 assert!(!BodyMatcher::Exists.matches(&Body::Empty));
246 }
247
248 #[test]
249 fn string_matchers_fail_non_text() {
250 let json_body = Body::Json(json!({"a": 1}));
251 let bytes_body = Body::Bytes(vec![97u8].into());
252 assert!(!BodyMatcher::Contains("a".into()).matches(&json_body));
253 assert!(!BodyMatcher::Contains("a".into()).matches(&bytes_body));
254 assert_eq!(
255 BodyMatcher::Contains("a".into()).mismatch_note(&json_body),
256 Some("body is not text")
257 );
258 assert_eq!(
259 BodyMatcher::Contains("a".into()).mismatch_note(&bytes_body),
260 Some("body is not text")
261 );
262 }
263
264 #[test]
265 fn json_subset_recursive_ignores_extra() {
266 let matcher = BodyMatcher::JsonSubset(json!({"status": "ok", "meta": {"seq": 3}}));
267 let body = Body::Json(json!({"id": 7, "status": "ok", "meta": {"seq": 3, "ts": 9}}));
268 assert!(matcher.matches(&body));
269 }
270
271 #[test]
272 fn json_subset_arrays_exact() {
273 let matcher = BodyMatcher::JsonSubset(json!({"tags": ["a", "b"]}));
274 assert!(!matcher.matches(&Body::Json(json!({"tags": ["b", "a"]}))));
275 assert!(matcher.matches(&Body::Json(json!({"tags": ["a", "b"]}))));
276 }
277
278 #[test]
279 fn json_subset_parses_text() {
280 let matcher = BodyMatcher::JsonSubset(json!({"status": "ok"}));
281 assert!(matcher.matches(&Body::Text("{\"status\": \"ok\"}".into())));
282 let bad = Body::Text("ok".into());
283 assert!(!matcher.matches(&bad));
284 assert_eq!(matcher.mismatch_note(&bad), Some("body is not JSON"));
285 assert!(!BodyMatcher::JsonSubset(json!(null)).matches(&Body::Json(json!(null))));
286 assert!(!BodyMatcher::JsonSubset(json!([1, 2])).matches(&Body::Json(json!([1, 2]))));
287 }
288
289 #[test]
290 fn json_subset_null_requires_null() {
291 let matcher = BodyMatcher::JsonSubset(json!({"err": null}));
292 assert!(matcher.matches(&Body::Json(json!({"err": null}))));
293 assert!(!matcher.matches(&Body::Json(json!({"err": 0}))));
294 }
295
296 #[test]
297 fn header_null_and_missing() {
298 assert!(HeaderMatcher::Exists.matches(Some(&Value::Null)));
299 assert!(!HeaderMatcher::Exists.matches(None));
300 assert!(HeaderMatcher::Equals(Value::Null).matches(Some(&Value::Null)));
301 let regex = HeaderMatcher::Regex("^a$".into());
302 assert!(!regex.matches(Some(&Value::Null)));
303 assert_eq!(
304 regex.mismatch_note(Some(&Value::Null)),
305 Some("value is not a string")
306 );
307 }
308}