Skip to main content

praxis_filter/condition/
request.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Request condition evaluation for gating filter execution.
5
6use praxis_core::config::{Condition, ConditionMatch};
7
8use crate::context::Request;
9
10// -----------------------------------------------------------------------------
11// Request Condition Evaluation
12// -----------------------------------------------------------------------------
13
14/// Returns true if the filter should execute given its conditions.
15///
16/// ```
17/// use praxis_core::config::{Condition, ConditionMatch};
18/// use praxis_filter::{Request, should_execute};
19///
20/// fn make_req(path: &str) -> Request {
21///     Request {
22///         headers: http::HeaderMap::new(),
23///         method: http::Method::GET,
24///         uri: path.parse().unwrap(),
25///     }
26/// }
27///
28/// // Empty conditions — always executes.
29/// let req = make_req("/api/v1");
30/// assert!(should_execute(&[], &req));
31///
32/// // When condition matches.
33/// let when = Condition::When(ConditionMatch {
34///     path: None,
35///     path_prefix: Some("/api".into()),
36///     methods: None,
37///     headers: None,
38/// });
39/// assert!(should_execute(&[when], &req));
40///
41/// // Unless condition matches — skipped.
42/// let unless = Condition::Unless(ConditionMatch {
43///     path: None,
44///     path_prefix: Some("/api".into()),
45///     methods: None,
46///     headers: None,
47/// });
48/// assert!(!should_execute(&[unless], &req));
49/// ```
50pub fn should_execute(conditions: &[Condition], req: &Request) -> bool {
51    for condition in conditions {
52        match condition {
53            Condition::When(m) => {
54                if !matches_request(m, req) {
55                    return false;
56                }
57            },
58            Condition::Unless(m) => {
59                if matches_request(m, req) {
60                    return false;
61                }
62            },
63        }
64    }
65    true
66}
67
68/// Returns true if all specified fields in the predicate match the request.
69/// Unset fields impose no constraint (vacuously true).
70fn matches_request(m: &ConditionMatch, req: &Request) -> bool {
71    if let Some(exact) = &m.path {
72        let req_path = req.uri.path();
73        if req_path != exact {
74            return false;
75        }
76    }
77
78    if let Some(prefix) = &m.path_prefix
79        && !crate::path_match::path_prefix_matches(req.uri.path(), prefix)
80    {
81        return false;
82    }
83
84    if let Some(methods) = &m.methods
85        && !methods
86            .iter()
87            .any(|method| method.eq_ignore_ascii_case(req.method.as_str()))
88    {
89        return false;
90    }
91
92    if let Some(headers) = &m.headers {
93        for (name, value) in headers {
94            match req.headers.get(name) {
95                Some(v) if v.to_str().ok() == Some(value.as_str()) => {},
96                _ => return false,
97            }
98        }
99    }
100
101    true
102}
103
104// -----------------------------------------------------------------------------
105// Tests
106// -----------------------------------------------------------------------------
107
108#[cfg(test)]
109#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
110#[allow(
111    clippy::unwrap_used,
112    clippy::expect_used,
113    clippy::indexing_slicing,
114    clippy::panic,
115    reason = "tests"
116)]
117mod tests {
118    use std::collections::HashMap;
119
120    use http::{HeaderMap, HeaderValue, Method, Uri};
121    use praxis_core::config::ConditionMatch;
122
123    use super::*;
124
125    #[test]
126    fn empty_conditions_always_execute() {
127        let req = make_request(Method::GET, "/anything", HeaderMap::new());
128        assert!(should_execute(&[], &req));
129    }
130
131    #[test]
132    fn when_path_matches() {
133        let req = make_request(Method::GET, "/api/users", HeaderMap::new());
134        assert!(should_execute(&[when(path_match("/api"))], &req));
135    }
136
137    #[test]
138    fn when_path_does_not_match() {
139        let req = make_request(Method::GET, "/health", HeaderMap::new());
140        assert!(!should_execute(&[when(path_match("/api"))], &req));
141    }
142
143    #[test]
144    fn when_method_matches() {
145        let req = make_request(Method::POST, "/", HeaderMap::new());
146        assert!(should_execute(&[when(method_match(&["POST", "PUT"]))], &req));
147    }
148
149    #[test]
150    fn when_method_does_not_match() {
151        let req = make_request(Method::GET, "/", HeaderMap::new());
152        assert!(!should_execute(&[when(method_match(&["POST", "PUT"]))], &req));
153    }
154
155    #[test]
156    fn when_method_case_insensitive() {
157        let req = make_request(Method::GET, "/", HeaderMap::new());
158        assert!(should_execute(&[when(method_match(&["get"]))], &req));
159    }
160
161    #[test]
162    fn when_header_matches() {
163        let mut headers = HeaderMap::new();
164        headers.insert("x-debug", HeaderValue::from_static("true"));
165        let req = make_request(Method::GET, "/", headers);
166        assert!(should_execute(&[when(header_match(&[("x-debug", "true")]))], &req));
167    }
168
169    #[test]
170    fn when_header_missing() {
171        let req = make_request(Method::GET, "/", HeaderMap::new());
172        assert!(!should_execute(&[when(header_match(&[("x-debug", "true")]))], &req));
173    }
174
175    #[test]
176    fn when_header_wrong_value() {
177        let mut headers = HeaderMap::new();
178        headers.insert("x-debug", HeaderValue::from_static("false"));
179        let req = make_request(Method::GET, "/", headers);
180        assert!(!should_execute(&[when(header_match(&[("x-debug", "true")]))], &req));
181    }
182
183    #[test]
184    fn unless_skips_when_matched() {
185        let req = make_request(Method::GET, "/healthz", HeaderMap::new());
186        assert!(!should_execute(&[unless(path_match("/healthz"))], &req));
187    }
188
189    #[test]
190    fn unless_runs_when_not_matched() {
191        let req = make_request(Method::GET, "/api/users", HeaderMap::new());
192        assert!(should_execute(&[unless(path_match("/healthz"))], &req));
193    }
194
195    #[test]
196    fn multiple_conditions_all_pass() {
197        let req = make_request(Method::POST, "/api/users", HeaderMap::new());
198        let conditions = vec![when(path_match("/api")), when(method_match(&["POST", "PUT"]))];
199        assert!(should_execute(&conditions, &req));
200    }
201
202    #[test]
203    fn first_condition_fails_short_circuits() {
204        let req = make_request(Method::POST, "/health", HeaderMap::new());
205        let conditions = vec![when(path_match("/api")), when(method_match(&["POST", "PUT"]))];
206        assert!(!should_execute(&conditions, &req));
207    }
208
209    #[test]
210    fn mixed_when_unless() {
211        let mut headers = HeaderMap::new();
212        headers.insert("x-internal", HeaderValue::from_static("true"));
213        let req = make_request(Method::POST, "/api/users", headers);
214
215        let conditions = vec![
216            when(path_match("/api")),
217            unless(header_match(&[("x-internal", "true")])),
218        ];
219        assert!(
220            !should_execute(&conditions, &req),
221            "unless should block when header matches"
222        );
223    }
224
225    #[test]
226    fn mixed_when_unless_all_pass() {
227        let req = make_request(Method::POST, "/api/users", HeaderMap::new());
228        let conditions = vec![
229            when(path_match("/api")),
230            unless(header_match(&[("x-internal", "true")])),
231            when(method_match(&["POST", "PUT", "DELETE"])),
232        ];
233        assert!(should_execute(&conditions, &req));
234    }
235
236    #[test]
237    fn exact_path_matches() {
238        let req = make_request(Method::GET, "/", HeaderMap::new());
239        assert!(should_execute(&[when(exact_path_match("/"))], &req));
240    }
241
242    #[test]
243    fn exact_path_does_not_match_subpath() {
244        let req = make_request(Method::GET, "/foo", HeaderMap::new());
245        assert!(!should_execute(&[when(exact_path_match("/"))], &req));
246    }
247
248    #[test]
249    fn exact_path_strips_query_string() {
250        let req = make_request(Method::GET, "/?query=1", HeaderMap::new());
251        assert!(should_execute(&[when(exact_path_match("/"))], &req));
252    }
253
254    #[test]
255    fn combined_path_and_method_both_match() {
256        let req = make_request(Method::POST, "/api/users", HeaderMap::new());
257        let m = ConditionMatch {
258            path: None,
259            path_prefix: Some("/api".to_owned()),
260            methods: Some(vec!["POST".to_owned()]),
261            headers: None,
262        };
263        assert!(should_execute(&[when(m)], &req));
264    }
265
266    #[test]
267    fn combined_path_matches_method_does_not() {
268        let req = make_request(Method::GET, "/api/users", HeaderMap::new());
269        let m = ConditionMatch {
270            path: None,
271            path_prefix: Some("/api".to_owned()),
272            methods: Some(vec!["POST".to_owned()]),
273            headers: None,
274        };
275        assert!(!should_execute(&[when(m)], &req));
276    }
277
278    #[test]
279    fn combined_method_matches_path_does_not() {
280        let req = make_request(Method::POST, "/health", HeaderMap::new());
281        let m = ConditionMatch {
282            path: None,
283            path_prefix: Some("/api".to_owned()),
284            methods: Some(vec!["POST".to_owned()]),
285            headers: None,
286        };
287        assert!(!should_execute(&[when(m)], &req));
288    }
289
290    #[test]
291    fn all_fields_match() {
292        let mut headers = HeaderMap::new();
293        headers.insert("x-debug", HeaderValue::from_static("true"));
294        let req = make_request(Method::POST, "/api/submit", headers);
295
296        let mut hdr_map = HashMap::new();
297        hdr_map.insert("x-debug".to_owned(), "true".to_owned());
298        let m = ConditionMatch {
299            path: None,
300            path_prefix: Some("/api".to_owned()),
301            methods: Some(vec!["POST".to_owned()]),
302            headers: Some(hdr_map),
303        };
304        assert!(should_execute(&[when(m)], &req));
305    }
306
307    #[test]
308    fn all_fields_one_fails() {
309        let mut headers = HeaderMap::new();
310        headers.insert("x-debug", HeaderValue::from_static("false"));
311        let req = make_request(Method::POST, "/api/submit", headers);
312
313        let mut hdr_map = HashMap::new();
314        hdr_map.insert("x-debug".to_owned(), "true".to_owned());
315        let m = ConditionMatch {
316            path: None,
317            path_prefix: Some("/api".to_owned()),
318            methods: Some(vec!["POST".to_owned()]),
319            headers: Some(hdr_map),
320        };
321        assert!(!should_execute(&[when(m)], &req));
322    }
323
324    #[test]
325    fn unless_with_method_and_path() {
326        let req = make_request(Method::GET, "/healthz", HeaderMap::new());
327        let m = ConditionMatch {
328            path: None,
329            path_prefix: Some("/healthz".to_owned()),
330            methods: Some(vec!["GET".to_owned()]),
331            headers: None,
332        };
333        assert!(
334            !should_execute(&[unless(m)], &req),
335            "unless should block when both fields match"
336        );
337    }
338
339    #[test]
340    fn unless_partial_match_allows_execution() {
341        let req = make_request(Method::POST, "/healthz", HeaderMap::new());
342        let m = ConditionMatch {
343            path: None,
344            path_prefix: Some("/healthz".to_owned()),
345            methods: Some(vec!["GET".to_owned()]),
346            headers: None,
347        };
348        assert!(
349            should_execute(&[unless(m)], &req),
350            "partial match should not block unless"
351        );
352    }
353
354    #[test]
355    fn empty_condition_match_is_vacuously_true() {
356        let req = make_request(Method::DELETE, "/any/path", HeaderMap::new());
357        let m = ConditionMatch {
358            path: None,
359            path_prefix: None,
360            methods: None,
361            headers: None,
362        };
363        assert!(should_execute(&[when(m)], &req), "empty match should be vacuously true");
364    }
365
366    #[test]
367    fn multiple_headers_all_must_match() {
368        let mut headers = HeaderMap::new();
369        headers.insert("x-a", HeaderValue::from_static("1"));
370        headers.insert("x-b", HeaderValue::from_static("2"));
371        let req = make_request(Method::GET, "/", headers);
372        assert!(should_execute(
373            &[when(header_match(&[("x-a", "1"), ("x-b", "2")]))],
374            &req
375        ));
376    }
377
378    #[test]
379    fn when_path_prefix_rejects_non_segment_boundary() {
380        let req = make_request(Method::GET, "/apikeys", HeaderMap::new());
381        assert!(
382            !should_execute(&[when(path_match("/api"))], &req),
383            "path prefix /api must not match /apikeys (non-segment boundary)"
384        );
385    }
386
387    #[test]
388    fn multiple_headers_one_missing_fails() {
389        let mut headers = HeaderMap::new();
390        headers.insert("x-a", HeaderValue::from_static("1"));
391        let req = make_request(Method::GET, "/", headers);
392        assert!(!should_execute(
393            &[when(header_match(&[("x-a", "1"), ("x-b", "2")]))],
394            &req
395        ));
396    }
397
398    #[test]
399    fn path_shorter_than_prefix_does_not_match() {
400        let req = make_request(Method::GET, "/api", HeaderMap::new());
401        assert!(
402            !should_execute(&[when(path_match("/api/v1"))], &req),
403            "path /api should not match prefix /api/v1"
404        );
405    }
406
407    // -------------------------------------------------------------------------
408    // Test Utilities
409    // -------------------------------------------------------------------------
410
411    /// Build a [`Request`] with the given method, path, and headers.
412    fn make_request(method: Method, path: &str, headers: HeaderMap) -> Request {
413        Request {
414            method,
415            uri: path.parse::<Uri>().unwrap(),
416            headers,
417        }
418    }
419
420    /// Build a `When` condition.
421    fn when(m: ConditionMatch) -> Condition {
422        Condition::When(m)
423    }
424
425    /// Build an `Unless` condition.
426    fn unless(m: ConditionMatch) -> Condition {
427        Condition::Unless(m)
428    }
429
430    /// Build a condition matching a path prefix.
431    fn path_match(prefix: &str) -> ConditionMatch {
432        ConditionMatch {
433            path: None,
434            path_prefix: Some(prefix.to_owned()),
435            methods: None,
436            headers: None,
437        }
438    }
439
440    /// Build a condition matching an exact path.
441    fn exact_path_match(path: &str) -> ConditionMatch {
442        ConditionMatch {
443            path: Some(path.to_owned()),
444            path_prefix: None,
445            methods: None,
446            headers: None,
447        }
448    }
449
450    /// Build a condition matching HTTP methods.
451    fn method_match(methods: &[&str]) -> ConditionMatch {
452        ConditionMatch {
453            path: None,
454            path_prefix: None,
455            methods: Some(methods.iter().map(|s| (*s).to_owned()).collect()),
456            headers: None,
457        }
458    }
459
460    /// Build a condition matching request headers.
461    fn header_match(pairs: &[(&str, &str)]) -> ConditionMatch {
462        let mut headers = HashMap::new();
463        for (k, v) in pairs {
464            headers.insert((*k).to_owned(), (*v).to_owned());
465        }
466        ConditionMatch {
467            path: None,
468            path_prefix: None,
469            methods: None,
470            headers: Some(headers),
471        }
472    }
473}