1use http::{HeaderMap, Method};
2use serde_json::Value;
3
4#[derive(Debug, Clone, Default)]
6pub struct RequestMatcher {
7 pub(crate) method: Option<Method>,
8 pub(crate) path: Option<String>,
9 pub(crate) headers: Vec<(String, String)>,
10 pub(crate) body_json: Option<Value>,
11 pub(crate) body_string: Option<String>,
12}
13
14impl RequestMatcher {
15 pub fn new() -> Self {
17 Self::default()
18 }
19
20 pub fn method(mut self, method: Method) -> Self {
22 self.method = Some(method);
23 self
24 }
25
26 pub fn path(mut self, path: impl Into<String>) -> Self {
28 self.path = Some(path.into());
29 self
30 }
31
32 pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
34 self.headers.push((key.into(), value.into()));
35 self
36 }
37
38 pub fn body_json(mut self, body: impl serde::Serialize) -> Self {
40 self.body_json =
41 Some(serde_json::to_value(body).expect("Failed to serialize body matcher"));
42 self
43 }
44
45 pub fn body_string(mut self, body: impl Into<String>) -> Self {
47 self.body_string = Some(body.into());
48 self
49 }
50
51 pub fn matches(&self, method: &Method, path: &str, headers: &HeaderMap, body: &[u8]) -> bool {
53 if let Some(m) = &self.method {
54 if m != method {
55 return false;
56 }
57 }
58
59 if let Some(p) = &self.path {
60 if p != path {
61 return false;
62 }
63 }
64
65 for (k, v) in &self.headers {
66 match headers.get(k) {
67 Some(val) => {
68 if val != v.as_str() {
69 return false;
70 }
71 }
72 None => return false,
73 }
74 }
75
76 if let Some(expected_json) = &self.body_json {
77 if let Ok(actual_json) = serde_json::from_slice::<Value>(body) {
78 if &actual_json != expected_json {
79 return false;
80 }
81 } else {
82 return false;
83 }
84 }
85
86 if let Some(expected_str) = &self.body_string {
87 if let Ok(actual_str) = std::str::from_utf8(body) {
88 if actual_str != expected_str {
89 return false;
90 }
91 } else {
92 return false;
93 }
94 }
95
96 true
97 }
98}
99
100#[cfg(test)]
101mod property_tests {
102 use super::*;
103 use proptest::prelude::*;
104 use serde_json::json;
105
106 fn method_strategy() -> impl Strategy<Value = Method> {
118 prop_oneof![
119 Just(Method::GET),
120 Just(Method::POST),
121 Just(Method::PUT),
122 Just(Method::DELETE),
123 Just(Method::PATCH),
124 Just(Method::HEAD),
125 Just(Method::OPTIONS),
126 ]
127 }
128
129 fn path_strategy() -> impl Strategy<Value = String> {
131 prop::string::string_regex("/api/[a-z]{3,8}(/[0-9]{1,5})?").unwrap()
132 }
133
134 fn header_name_strategy() -> impl Strategy<Value = String> {
136 prop_oneof![
137 Just("Content-Type".to_string()),
138 Just("Authorization".to_string()),
139 Just("X-Request-Id".to_string()),
140 Just("Accept".to_string()),
141 ]
142 }
143
144 fn header_value_strategy() -> impl Strategy<Value = String> {
146 prop_oneof![
147 Just("application/json".to_string()),
148 Just("Bearer token123".to_string()),
149 Just("text/plain".to_string()),
150 prop::string::string_regex("[a-z0-9-]{5,15}").unwrap(),
151 ]
152 }
153
154 fn json_body_strategy() -> impl Strategy<Value = Value> {
156 prop_oneof![
157 Just(json!({"name": "test"})),
158 Just(json!({"id": 123, "status": "active"})),
159 Just(json!({"data": [1, 2, 3]})),
160 Just(json!({"message": "hello"})),
161 ]
162 }
163
164 proptest! {
165 #![proptest_config(ProptestConfig::with_cases(100))]
166
167 #[test]
169 fn prop_empty_matcher_matches_all(
170 method in method_strategy(),
171 path in path_strategy(),
172 body in "[a-zA-Z0-9]{0,50}",
173 ) {
174 let matcher = RequestMatcher::new();
175 let headers = HeaderMap::new();
176
177 prop_assert!(matcher.matches(&method, &path, &headers, body.as_bytes()));
179 }
180
181 #[test]
183 fn prop_method_matcher_correctness(
184 target_method in method_strategy(),
185 other_method in method_strategy(),
186 path in path_strategy(),
187 ) {
188 let matcher = RequestMatcher::new().method(target_method.clone());
189 let headers = HeaderMap::new();
190 let body = b"";
191
192 prop_assert!(matcher.matches(&target_method, &path, &headers, body));
194
195 if target_method != other_method {
197 prop_assert!(!matcher.matches(&other_method, &path, &headers, body));
198 }
199 }
200
201 #[test]
203 fn prop_path_matcher_exact(
204 method in method_strategy(),
205 target_path in path_strategy(),
206 other_path in path_strategy(),
207 ) {
208 let matcher = RequestMatcher::new().path(target_path.clone());
209 let headers = HeaderMap::new();
210 let body = b"";
211
212 prop_assert!(matcher.matches(&method, &target_path, &headers, body));
214
215 if target_path != other_path {
217 prop_assert!(!matcher.matches(&method, &other_path, &headers, body));
218 }
219 }
220
221 #[test]
223 fn prop_header_matcher_exact(
224 method in method_strategy(),
225 path in path_strategy(),
226 header_name in header_name_strategy(),
227 header_value in header_value_strategy(),
228 other_value in header_value_strategy(),
229 ) {
230 let matcher = RequestMatcher::new()
231 .header(header_name.clone(), header_value.clone());
232
233 let mut headers_match = HeaderMap::new();
234 headers_match.insert(
235 http::header::HeaderName::from_bytes(header_name.as_bytes()).unwrap(),
236 http::header::HeaderValue::from_str(&header_value).unwrap(),
237 );
238
239 let body = b"";
240
241 prop_assert!(matcher.matches(&method, &path, &headers_match, body));
243
244 if header_value != other_value {
246 let mut headers_differ = HeaderMap::new();
247 headers_differ.insert(
248 http::header::HeaderName::from_bytes(header_name.as_bytes()).unwrap(),
249 http::header::HeaderValue::from_str(&other_value).unwrap(),
250 );
251 prop_assert!(!matcher.matches(&method, &path, &headers_differ, body));
252 }
253
254 let headers_empty = HeaderMap::new();
256 prop_assert!(!matcher.matches(&method, &path, &headers_empty, body));
257 }
258
259 #[test]
261 fn prop_json_body_matcher_exact(
262 method in method_strategy(),
263 path in path_strategy(),
264 json_body in json_body_strategy(),
265 ) {
266 let matcher = RequestMatcher::new().body_json(json_body.clone());
267 let headers = HeaderMap::new();
268
269 let matching_body = serde_json::to_vec(&json_body).unwrap();
270
271 prop_assert!(matcher.matches(&method, &path, &headers, &matching_body));
273
274 let different_json = json!({"different": "value"});
276 let different_body = serde_json::to_vec(&different_json).unwrap();
277 prop_assert!(!matcher.matches(&method, &path, &headers, &different_body));
278
279 let invalid_json = b"not json at all";
281 prop_assert!(!matcher.matches(&method, &path, &headers, invalid_json));
282 }
283
284 #[test]
286 fn prop_string_body_matcher_exact(
287 method in method_strategy(),
288 path in path_strategy(),
289 body_string in "[a-zA-Z0-9 ]{5,30}",
290 other_string in "[a-zA-Z0-9 ]{5,30}",
291 ) {
292 let matcher = RequestMatcher::new().body_string(body_string.clone());
293 let headers = HeaderMap::new();
294
295 prop_assert!(matcher.matches(&method, &path, &headers, body_string.as_bytes()));
297
298 if body_string != other_string {
300 prop_assert!(!matcher.matches(&method, &path, &headers, other_string.as_bytes()));
301 }
302 }
303
304 #[test]
306 fn prop_multiple_criteria_and_logic(
307 target_method in method_strategy(),
308 other_method in method_strategy(),
309 target_path in path_strategy(),
310 other_path in path_strategy(),
311 header_name in header_name_strategy(),
312 header_value in header_value_strategy(),
313 ) {
314 let matcher = RequestMatcher::new()
315 .method(target_method.clone())
316 .path(target_path.clone())
317 .header(header_name.clone(), header_value.clone());
318
319 let mut headers_correct = HeaderMap::new();
320 headers_correct.insert(
321 http::header::HeaderName::from_bytes(header_name.as_bytes()).unwrap(),
322 http::header::HeaderValue::from_str(&header_value).unwrap(),
323 );
324
325 let body = b"";
326
327 prop_assert!(matcher.matches(&target_method, &target_path, &headers_correct, body));
329
330 if target_method != other_method {
332 prop_assert!(!matcher.matches(&other_method, &target_path, &headers_correct, body));
334 }
335
336 if target_path != other_path {
337 prop_assert!(!matcher.matches(&target_method, &other_path, &headers_correct, body));
339 }
340
341 let headers_empty = HeaderMap::new();
343 prop_assert!(!matcher.matches(&target_method, &target_path, &headers_empty, body));
344 }
345
346 #[test]
348 fn prop_path_case_sensitive(
349 method in method_strategy(),
350 path in "[a-z]{5,10}",
351 ) {
352 let lowercase_path = format!("/api/{}", path.to_lowercase());
353 let uppercase_path = format!("/api/{}", path.to_uppercase());
354
355 let matcher = RequestMatcher::new().path(lowercase_path.clone());
356 let headers = HeaderMap::new();
357 let body = b"";
358
359 prop_assert!(matcher.matches(&method, &lowercase_path, &headers, body));
361
362 if lowercase_path != uppercase_path {
364 prop_assert!(!matcher.matches(&method, &uppercase_path, &headers, body));
365 }
366 }
367
368 #[test]
370 fn prop_multiple_headers_all_match(
371 method in method_strategy(),
372 path in path_strategy(),
373 ) {
374 let matcher = RequestMatcher::new()
375 .header("Content-Type", "application/json")
376 .header("X-Request-Id", "req-123")
377 .header("Authorization", "Bearer token");
378
379 let mut headers_all = HeaderMap::new();
380 headers_all.insert("content-type", "application/json".parse().unwrap());
381 headers_all.insert("x-request-id", "req-123".parse().unwrap());
382 headers_all.insert("authorization", "Bearer token".parse().unwrap());
383
384 let mut headers_missing_one = HeaderMap::new();
385 headers_missing_one.insert("content-type", "application/json".parse().unwrap());
386 headers_missing_one.insert("x-request-id", "req-123".parse().unwrap());
387 let body = b"";
390
391 prop_assert!(matcher.matches(&method, &path, &headers_all, body));
393
394 prop_assert!(!matcher.matches(&method, &path, &headers_missing_one, body));
396 }
397
398 #[test]
400 fn prop_json_whitespace_normalized(
401 method in method_strategy(),
402 path in path_strategy(),
403 ) {
404 let json_value = json!({"name": "test", "id": 123});
405 let matcher = RequestMatcher::new().body_json(json_value.clone());
406 let headers = HeaderMap::new();
407
408 let compact = serde_json::to_vec(&json_value).unwrap();
410 prop_assert!(matcher.matches(&method, &path, &headers, &compact));
411
412 let pretty = serde_json::to_vec_pretty(&json_value).unwrap();
414 prop_assert!(matcher.matches(&method, &path, &headers, &pretty));
415 }
416
417 #[test]
419 fn prop_json_field_order_normalized(
420 method in method_strategy(),
421 path in path_strategy(),
422 ) {
423 let json_ordered = json!({"a": 1, "b": 2, "c": 3});
424 let json_reordered = json!({"c": 3, "a": 1, "b": 2});
425
426 let matcher = RequestMatcher::new().body_json(json_ordered);
427 let headers = HeaderMap::new();
428
429 let body = serde_json::to_vec(&json_reordered).unwrap();
430
431 prop_assert!(matcher.matches(&method, &path, &headers, &body));
433 }
434
435 #[test]
437 fn prop_default_matcher_permissive(
438 method in method_strategy(),
439 path in path_strategy(),
440 body in prop::collection::vec(0u8..255u8, 0..100),
441 ) {
442 let matcher = RequestMatcher::default();
443 let headers = HeaderMap::new();
444
445 prop_assert!(matcher.matches(&method, &path, &headers, &body));
447 }
448 }
449}