Skip to main content

rustapi_testing/
matcher.rs

1use http::{HeaderMap, Method};
2use serde_json::Value;
3
4/// Matcher for HTTP requests
5#[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    /// Create a new matcher
16    pub fn new() -> Self {
17        Self::default()
18    }
19
20    /// Match a specific HTTP method
21    pub fn method(mut self, method: Method) -> Self {
22        self.method = Some(method);
23        self
24    }
25
26    /// Match a specific path
27    pub fn path(mut self, path: impl Into<String>) -> Self {
28        self.path = Some(path.into());
29        self
30    }
31
32    /// Match a specific header
33    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    /// Match exact JSON body
39    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    /// Match exact string body
46    pub fn body_string(mut self, body: impl Into<String>) -> Self {
47        self.body_string = Some(body.into());
48        self
49    }
50
51    /// Check if the matcher matches a request
52    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    /// **Feature: v1-features-roadmap, Property 20: Mock server request matching**
107    /// **Validates: Requirements 9.1**
108    ///
109    /// For any HTTP request matcher:
110    /// - Matcher SHALL correctly identify matching requests
111    /// - Matcher SHALL correctly reject non-matching requests
112    /// - Empty matcher SHALL match all requests
113    /// - Multiple criteria SHALL be combined with AND logic
114    /// - Header matching SHALL be case-sensitive for values
115
116    /// Strategy for generating HTTP methods
117    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    /// Strategy for generating paths
130    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    /// Strategy for generating header names
135    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    /// Strategy for generating header values
145    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    /// Strategy for generating JSON bodies
155    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        /// Property 20: Empty matcher matches all requests
168        #[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            // Empty matcher MUST match any request
178            prop_assert!(matcher.matches(&method, &path, &headers, body.as_bytes()));
179        }
180
181        /// Property 20: Method matcher correctly identifies method
182        #[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            // MUST match requests with same method
193            prop_assert!(matcher.matches(&target_method, &path, &headers, body));
194
195            // MUST reject requests with different method
196            if target_method != other_method {
197                prop_assert!(!matcher.matches(&other_method, &path, &headers, body));
198            }
199        }
200
201        /// Property 20: Path matcher is exact match
202        #[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            // MUST match exact path
213            prop_assert!(matcher.matches(&method, &target_path, &headers, body));
214
215            // MUST reject different path
216            if target_path != other_path {
217                prop_assert!(!matcher.matches(&method, &other_path, &headers, body));
218            }
219        }
220
221        /// Property 20: Header matcher requires exact value
222        #[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            // MUST match when header value is exact
242            prop_assert!(matcher.matches(&method, &path, &headers_match, body));
243
244            // MUST reject when header value differs
245            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            // MUST reject when header is missing
255            let headers_empty = HeaderMap::new();
256            prop_assert!(!matcher.matches(&method, &path, &headers_empty, body));
257        }
258
259        /// Property 20: JSON body matcher requires exact match
260        #[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            // MUST match exact JSON body
272            prop_assert!(matcher.matches(&method, &path, &headers, &matching_body));
273
274            // MUST reject different JSON body
275            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            // MUST reject invalid JSON
280            let invalid_json = b"not json at all";
281            prop_assert!(!matcher.matches(&method, &path, &headers, invalid_json));
282        }
283
284        /// Property 20: String body matcher requires exact match
285        #[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            // MUST match exact string
296            prop_assert!(matcher.matches(&method, &path, &headers, body_string.as_bytes()));
297
298            // MUST reject different string
299            if body_string != other_string {
300                prop_assert!(!matcher.matches(&method, &path, &headers, other_string.as_bytes()));
301            }
302        }
303
304        /// Property 20: Multiple criteria combined with AND logic
305        #[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            // MUST match when ALL criteria match
328            prop_assert!(matcher.matches(&target_method, &target_path, &headers_correct, body));
329
330            // MUST reject when ANY criterion fails
331            if target_method != other_method {
332                // Wrong method
333                prop_assert!(!matcher.matches(&other_method, &target_path, &headers_correct, body));
334            }
335
336            if target_path != other_path {
337                // Wrong path
338                prop_assert!(!matcher.matches(&target_method, &other_path, &headers_correct, body));
339            }
340
341            // Wrong/missing header
342            let headers_empty = HeaderMap::new();
343            prop_assert!(!matcher.matches(&target_method, &target_path, &headers_empty, body));
344        }
345
346        /// Property 20: Matcher is case-sensitive for paths
347        #[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            // MUST match exact case
360            prop_assert!(matcher.matches(&method, &lowercase_path, &headers, body));
361
362            // MUST reject different case (if different)
363            if lowercase_path != uppercase_path {
364                prop_assert!(!matcher.matches(&method, &uppercase_path, &headers, body));
365            }
366        }
367
368        /// Property 20: Multiple headers must all match
369        #[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            // Missing Authorization
388
389            let body = b"";
390
391            // MUST match when all headers present
392            prop_assert!(matcher.matches(&method, &path, &headers_all, body));
393
394            // MUST reject when any header missing
395            prop_assert!(!matcher.matches(&method, &path, &headers_missing_one, body));
396        }
397
398        /// Property 20: JSON body whitespace doesn't affect matching
399        #[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            // Compact JSON
409            let compact = serde_json::to_vec(&json_value).unwrap();
410            prop_assert!(matcher.matches(&method, &path, &headers, &compact));
411
412            // Pretty-printed JSON (different whitespace)
413            let pretty = serde_json::to_vec_pretty(&json_value).unwrap();
414            prop_assert!(matcher.matches(&method, &path, &headers, &pretty));
415        }
416
417        /// Property 20: JSON field order doesn't affect matching
418        #[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            // MUST match regardless of field order (JSON semantics)
432            prop_assert!(matcher.matches(&method, &path, &headers, &body));
433        }
434
435        /// Property 20: Matcher with no criteria matches everything
436        #[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            // Default matcher MUST be permissive
446            prop_assert!(matcher.matches(&method, &path, &headers, &body));
447        }
448    }
449}