1use praxis_core::config::{Condition, ConditionMatch};
7
8use crate::context::Request;
9
10pub 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
68fn 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#[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 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 fn when(m: ConditionMatch) -> Condition {
422 Condition::When(m)
423 }
424
425 fn unless(m: ConditionMatch) -> Condition {
427 Condition::Unless(m)
428 }
429
430 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 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 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 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}