Skip to main content

mockd/
router.rs

1//! Route matching.
2//!
3//! The [`Router`] owns the compiled set of mock routes and answers the
4//! question *"given this request, which route matches and what should the
5//! response be?"*.
6//!
7//! ## Matching algorithm
8//!
9//! Routes are evaluated in declaration order; the first matching route wins.
10//! A route matches when:
11//!
12//! 1. the HTTP [`Method`] equals the request method,
13//! 2. the path pattern matches the request path segment by segment
14//!    (capturing `{param}` segments), and
15//! 3. every rule in the optional `when` block ([`RequestMatch`]) is
16//!    satisfied: required query parameters, headers (case-insensitively)
17//!    and a JSON body subset.
18
19use std::collections::HashMap;
20use std::sync::atomic::{AtomicUsize, Ordering};
21use std::sync::Arc;
22
23use serde_json::Value;
24
25use crate::config::{Method, RequestMatch, ResponseConfig, Route};
26
27/// A compiled set of routes ready to answer requests.
28#[derive(Debug, Clone)]
29pub struct Router {
30    routes: Vec<CompiledRoute>,
31}
32
33#[derive(Debug, Clone)]
34struct CompiledRoute {
35    method: Method,
36    segments: Vec<Segment>,
37    when: Option<RequestMatch>,
38    /// The list of responses for this route. A single-element vec means a
39    /// plain static route; a longer vec is a sequence whose counter advances
40    /// on each match (last item is sticky).
41    responses: Vec<ResponseConfig>,
42    /// Per-route counter for sequence responses. Shared across `Router`
43    /// clones so that all callers observe the same progression.
44    counter: Arc<AtomicUsize>,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48enum Segment {
49    Literal(String),
50    Param(String),
51}
52
53/// The result of matching a request against the [`Router`].
54#[derive(Debug, Clone)]
55pub struct Match {
56    /// Captured path parameters (e.g. `{"id": "42"}`).
57    pub path_params: HashMap<String, String>,
58    /// The response that should be produced.
59    pub response: ResponseConfig,
60}
61
62impl Router {
63    /// Compile a set of routes.
64    ///
65    /// Returns an error if any route has an invalid path pattern or an empty
66    /// `sequence: []` response spec.
67    pub fn new(routes: Vec<Route>) -> Result<Self, RouterError> {
68        let mut compiled = Vec::with_capacity(routes.len());
69        for (index, route) in routes.into_iter().enumerate() {
70            let segments = compile_path(&route.path).map_err(|e| RouterError::InvalidPath {
71                route_index: index,
72                source: e,
73            })?;
74            let responses = route.response.into_responses();
75            if responses.is_empty() {
76                return Err(RouterError::EmptySequence { route_index: index });
77            }
78            compiled.push(CompiledRoute {
79                method: route.method,
80                segments,
81                when: route.when,
82                responses,
83                counter: Arc::new(AtomicUsize::new(0)),
84            });
85        }
86        Ok(Router { routes: compiled })
87    }
88
89    /// Number of compiled routes.
90    pub fn len(&self) -> usize {
91        self.routes.len()
92    }
93
94    /// Whether the router has no routes.
95    pub fn is_empty(&self) -> bool {
96        self.routes.is_empty()
97    }
98
99    /// Resolve a request to a [`Match`].
100    ///
101    /// All inputs use plain, server-agnostic types. `headers` should use
102    /// lower-cased header names; header *matching* against route rules is
103    /// performed case-insensitively regardless.
104    ///
105    /// For sequence routes, each successful match advances the internal
106    /// counter; the last response in the sequence is repeated forever.
107    pub fn resolve(
108        &self,
109        method: Method,
110        path: &str,
111        query: &HashMap<String, String>,
112        headers: &HashMap<String, String>,
113        body: &Value,
114    ) -> Option<Match> {
115        let request_segments: Vec<&str> = path_segments(path).collect();
116
117        for route in &self.routes {
118            if route.method != method {
119                continue;
120            }
121            if let Some(path_params) = match_path(&route.segments, &request_segments) {
122                if match_when(route.when.as_ref(), query, headers, body) {
123                    let response = pick_response(route);
124                    return Some(Match {
125                        path_params,
126                        response,
127                    });
128                }
129            }
130        }
131        None
132    }
133}
134
135/// Select the response for a matched route.
136///
137/// For a single-response route this is the only item. For a sequence route,
138/// each call returns the next item until the last is reached, after which the
139/// last item is returned on every subsequent call (sticky last).
140fn pick_response(route: &CompiledRoute) -> ResponseConfig {
141    let n = route.responses.len();
142    if n == 1 {
143        return route.responses[0].clone();
144    }
145    let idx = route.counter.fetch_add(1, Ordering::Relaxed);
146    // Once we've passed the end, keep returning the last response.
147    let clamped = idx.min(n - 1);
148    route.responses[clamped].clone()
149}
150
151/// Split a request path into non-empty segments, ignoring the leading slash.
152fn path_segments(path: &str) -> impl Iterator<Item = &str> {
153    path.trim_end_matches('/')
154        .split('/')
155        .filter(|s| !s.is_empty())
156}
157
158/// Compile a path pattern into [`Segment`]s.
159///
160/// Patterns look like `/users/{id}/items/{itemId}`. A `{name}` placeholder
161/// captures a single path segment. The pattern must be well-formed: balanced
162/// braces and a non-empty name.
163fn compile_path(pattern: &str) -> Result<Vec<Segment>, PathError> {
164    let mut segments = Vec::new();
165    for raw in pattern.split('/') {
166        if raw.is_empty() {
167            continue;
168        }
169        if let Some(name) = raw.strip_prefix('{').and_then(|r| r.strip_suffix('}')) {
170            if name.is_empty() || name.contains('{') || name.contains('}') {
171                return Err(PathError::InvalidParam(raw.to_string()));
172            }
173            segments.push(Segment::Param(name.to_string()));
174        } else if raw.contains('{') || raw.contains('}') {
175            return Err(PathError::UnbalancedBraces(raw.to_string()));
176        } else {
177            segments.push(Segment::Literal(raw.to_string()));
178        }
179    }
180    if segments.is_empty() {
181        return Err(PathError::EmptyPattern);
182    }
183    Ok(segments)
184}
185
186/// Match compiled segments against request segments, capturing params.
187///
188/// Returns the captured params if the segments match, or `None` otherwise.
189fn match_path(segments: &[Segment], request: &[&str]) -> Option<HashMap<String, String>> {
190    if segments.len() != request.len() {
191        return None;
192    }
193    let mut params = HashMap::with_capacity(segments.len());
194    for (seg, req) in segments.iter().zip(request.iter()) {
195        match seg {
196            Segment::Literal(lit) => {
197                if lit != req {
198                    return None;
199                }
200            }
201            Segment::Param(name) => {
202                params.insert(name.clone(), (*req).to_string());
203            }
204        }
205    }
206    Some(params)
207}
208
209/// Evaluate the optional `when` block.
210fn match_when(
211    when: Option<&RequestMatch>,
212    query: &HashMap<String, String>,
213    headers: &HashMap<String, String>,
214    body: &Value,
215) -> bool {
216    let Some(when) = when else {
217        return true;
218    };
219    when.query
220        .iter()
221        .all(|(k, v)| query.get(k).map(|actual| actual == v).unwrap_or(false))
222        && when.headers.iter().all(|(k, v)| {
223            let lower = k.to_ascii_lowercase();
224            headers
225                .get(&lower)
226                .map(|actual| actual.eq_ignore_ascii_case(v))
227                .unwrap_or(false)
228        })
229        && when
230            .body
231            .as_ref()
232            .map(|pattern| body_matches(pattern, body))
233            .unwrap_or(true)
234}
235
236/// Subset match between a JSON pattern and the actual request body.
237///
238/// - Objects: every key in the pattern must be present and recursively match.
239/// - Arrays: must have the same length and match element by element.
240/// - Scalars: equality.
241fn body_matches(pattern: &Value, actual: &Value) -> bool {
242    match (pattern, actual) {
243        (Value::Object(pattern), Value::Object(actual)) => pattern.iter().all(|(key, value)| {
244            actual
245                .get(key)
246                .map(|a| body_matches(value, a))
247                .unwrap_or(false)
248        }),
249        (Value::Array(pattern), Value::Array(actual)) => {
250            pattern.len() == actual.len()
251                && pattern.iter().zip(actual).all(|(p, a)| body_matches(p, a))
252        }
253        _ => pattern == actual,
254    }
255}
256
257/// Errors that can occur while building a [`Router`].
258#[derive(Debug, thiserror::Error)]
259pub enum RouterError {
260    /// A route's path pattern could not be compiled.
261    #[error("invalid path pattern in route {route_index}: {source}")]
262    InvalidPath {
263        route_index: usize,
264        #[source]
265        source: PathError,
266    },
267
268    /// A `response.sequence` was empty.
269    #[error("empty `sequence` in route {route_index}; expected at least one item")]
270    EmptySequence { route_index: usize },
271}
272
273/// Errors in an individual path pattern.
274#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
275pub enum PathError {
276    /// A `{...}` placeholder was malformed.
277    #[error("invalid path parameter `{0}`")]
278    InvalidParam(String),
279    /// Curly braces appear outside a placeholder.
280    #[error("unbalanced braces in segment `{0}`")]
281    UnbalancedBraces(String),
282    /// The pattern contained no segments.
283    #[error("path pattern is empty")]
284    EmptyPattern,
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::config::{Method, RequestMatch, ResponseConfig, ResponseSpec};
291    use serde_json::json;
292
293    fn route(method: Method, path: &str) -> Route {
294        Route {
295            method,
296            path: path.to_string(),
297            when: None,
298            response: ResponseSpec::Single(ResponseConfig::default()),
299        }
300    }
301
302    /// Helper: build a route whose single response has the given status.
303    fn route_with_status(method: Method, path: &str, status: u16) -> Route {
304        let mut r = route(method, path);
305        if let ResponseSpec::Single(resp) = &mut r.response {
306            resp.status = status;
307        }
308        r
309    }
310
311    fn empty_inputs() -> (HashMap<String, String>, HashMap<String, String>, Value) {
312        (HashMap::new(), HashMap::new(), Value::Null)
313    }
314
315    #[test]
316    fn literal_path_matches() {
317        let router = Router::new(vec![route(Method::Get, "/users")]).unwrap();
318        let (q, h, b) = empty_inputs();
319        let m = router.resolve(Method::Get, "/users", &q, &h, &b);
320        assert!(m.is_some());
321    }
322
323    #[test]
324    fn leading_slash_optional() {
325        let router = Router::new(vec![route(Method::Get, "/users")]).unwrap();
326        let (q, h, b) = empty_inputs();
327        assert!(router.resolve(Method::Get, "users", &q, &h, &b).is_some());
328    }
329
330    #[test]
331    fn method_must_match() {
332        let router = Router::new(vec![route(Method::Get, "/users")]).unwrap();
333        let (q, h, b) = empty_inputs();
334        assert!(router.resolve(Method::Post, "/users", &q, &h, &b).is_none());
335    }
336
337    #[test]
338    fn captures_path_params() {
339        let router = Router::new(vec![route(Method::Get, "/users/{id}/items/{itemId}")]).unwrap();
340        let (q, h, b) = empty_inputs();
341        let m = router
342            .resolve(Method::Get, "/users/42/items/7", &q, &h, &b)
343            .unwrap();
344        assert_eq!(m.path_params.get("id").unwrap(), "42");
345        assert_eq!(m.path_params.get("itemId").unwrap(), "7");
346    }
347
348    #[test]
349    fn segment_count_must_match() {
350        let router = Router::new(vec![route(Method::Get, "/users/{id}")]).unwrap();
351        let (q, h, b) = empty_inputs();
352        assert!(router
353            .resolve(Method::Get, "/users/42/items", &q, &h, &b)
354            .is_none());
355    }
356
357    #[test]
358    fn first_match_wins() {
359        let r1 = route_with_status(Method::Get, "/users/{id}", 200);
360        let r2 = route_with_status(Method::Get, "/users/{id}", 201);
361        let router = Router::new(vec![r1, r2]).unwrap();
362        let (q, h, b) = empty_inputs();
363        let m = router.resolve(Method::Get, "/users/1", &q, &h, &b).unwrap();
364        assert_eq!(m.response.status, 200);
365    }
366
367    #[test]
368    fn matches_query_param() {
369        let mut r = route(Method::Get, "/users");
370        r.when = Some(RequestMatch {
371            query: [("role".to_string(), "admin".to_string())].into(),
372            ..Default::default()
373        });
374        let router = Router::new(vec![r]).unwrap();
375        let (mut q, h, b) = empty_inputs();
376        assert!(router.resolve(Method::Get, "/users", &q, &h, &b).is_none());
377        q.insert("role".into(), "admin".into());
378        assert!(router.resolve(Method::Get, "/users", &q, &h, &b).is_some());
379    }
380
381    #[test]
382    fn matches_header_case_insensitively() {
383        let mut r = route(Method::Get, "/users");
384        r.when = Some(RequestMatch {
385            headers: [("X-Tenant-Id".to_string(), "tenant-a".to_string())].into(),
386            ..Default::default()
387        });
388        let router = Router::new(vec![r]).unwrap();
389        let (q, mut h, b) = empty_inputs();
390        h.insert("x-tenant-id".into(), "TENANT-A".into());
391        assert!(router.resolve(Method::Get, "/users", &q, &h, &b).is_some());
392    }
393
394    #[test]
395    fn matches_body_subset() {
396        let mut r = route(Method::Post, "/login");
397        r.when = Some(RequestMatch {
398            body: Some(json!({"username": "admin"})),
399            ..Default::default()
400        });
401        let router = Router::new(vec![r]).unwrap();
402        let (q, h, _) = empty_inputs();
403        let body = json!({"username": "admin", "password": "secret"});
404        assert!(router
405            .resolve(Method::Post, "/login", &q, &h, &body)
406            .is_some());
407        let other = json!({"username": "guest"});
408        assert!(router
409            .resolve(Method::Post, "/login", &q, &h, &other)
410            .is_none());
411    }
412
413    #[test]
414    fn when_block_can_disambiguate_same_path() {
415        // Two routes with the same path: the one without `when` is a fallback,
416        // the one with `when` is more specific. Declaring the specific one
417        // first makes it win for matching requests.
418        let mut admin = route_with_status(Method::Get, "/users", 201);
419        admin.when = Some(RequestMatch {
420            query: [("role".to_string(), "admin".to_string())].into(),
421            ..Default::default()
422        });
423        let generic = route_with_status(Method::Get, "/users", 200);
424        let router = Router::new(vec![admin, generic]).unwrap();
425
426        let (mut q, h, b) = empty_inputs();
427        q.insert("role".into(), "admin".into());
428        let m = router.resolve(Method::Get, "/users", &q, &h, &b).unwrap();
429        assert_eq!(m.response.status, 201);
430
431        q.clear();
432        let m = router.resolve(Method::Get, "/users", &q, &h, &b).unwrap();
433        assert_eq!(m.response.status, 200);
434    }
435
436    #[test]
437    fn rejects_invalid_path_pattern_empty_param() {
438        let routes = vec![route(Method::Get, "/users/{}")];
439        assert!(Router::new(routes).is_err());
440    }
441
442    #[test]
443    fn rejects_invalid_path_pattern_unbalanced() {
444        let routes = vec![route(Method::Get, "/users/{id")];
445        assert!(Router::new(routes).is_err());
446    }
447
448    #[test]
449    fn rejects_empty_pattern() {
450        let routes = vec![route(Method::Get, "/")];
451        assert!(Router::new(routes).is_err());
452    }
453
454    // -----------------------------------------------------------------
455    // Sequence responses
456    // -----------------------------------------------------------------
457
458    fn sequence_route(method: Method, path: &str, statuses: Vec<u16>) -> Route {
459        let sequence = statuses
460            .into_iter()
461            .map(|status| ResponseConfig {
462                status,
463                ..ResponseConfig::default()
464            })
465            .collect();
466        Route {
467            method,
468            path: path.to_string(),
469            when: None,
470            response: ResponseSpec::Sequence { sequence },
471        }
472    }
473
474    #[test]
475    fn sequence_returns_responses_in_order() {
476        let router = Router::new(vec![sequence_route(
477            Method::Get,
478            "/flaky",
479            vec![500, 500, 200],
480        )])
481        .unwrap();
482        let (q, h, b) = empty_inputs();
483
484        assert_eq!(
485            router
486                .resolve(Method::Get, "/flaky", &q, &h, &b)
487                .unwrap()
488                .response
489                .status,
490            500
491        );
492        assert_eq!(
493            router
494                .resolve(Method::Get, "/flaky", &q, &h, &b)
495                .unwrap()
496                .response
497                .status,
498            500
499        );
500        assert_eq!(
501            router
502                .resolve(Method::Get, "/flaky", &q, &h, &b)
503                .unwrap()
504                .response
505                .status,
506            200
507        );
508    }
509
510    #[test]
511    fn sequence_sticks_on_last_response_after_exhausting() {
512        let router =
513            Router::new(vec![sequence_route(Method::Get, "/retry", vec![500, 200])]).unwrap();
514        let (q, h, b) = empty_inputs();
515
516        // Consume the whole sequence.
517        router.resolve(Method::Get, "/retry", &q, &h, &b);
518        router.resolve(Method::Get, "/retry", &q, &h, &b);
519
520        // Subsequent calls keep returning the last one.
521        for _ in 0..5 {
522            assert_eq!(
523                router
524                    .resolve(Method::Get, "/retry", &q, &h, &b)
525                    .unwrap()
526                    .response
527                    .status,
528                200
529            );
530        }
531    }
532
533    #[test]
534    fn sequence_state_is_shared_between_router_clones() {
535        // The Router is cloned per Axum worker; all clones must observe the
536        // same sequence progression.
537        let router = Router::new(vec![sequence_route(Method::Get, "/x", vec![1, 2, 3])]).unwrap();
538        let cloned = router.clone();
539        let (q, h, b) = empty_inputs();
540
541        // Interleave calls from both clones.
542        assert_eq!(
543            router
544                .resolve(Method::Get, "/x", &q, &h, &b)
545                .unwrap()
546                .response
547                .status,
548            1
549        );
550        assert_eq!(
551            cloned
552                .resolve(Method::Get, "/x", &q, &h, &b)
553                .unwrap()
554                .response
555                .status,
556            2
557        );
558        assert_eq!(
559            router
560                .resolve(Method::Get, "/x", &q, &h, &b)
561                .unwrap()
562                .response
563                .status,
564            3
565        );
566        // Exhausted -> sticks on 3.
567        assert_eq!(
568            cloned
569                .resolve(Method::Get, "/x", &q, &h, &b)
570                .unwrap()
571                .response
572                .status,
573            3
574        );
575    }
576
577    #[test]
578    fn empty_sequence_is_rejected_at_compile_time() {
579        let r = Route {
580            method: Method::Get,
581            path: "/x".to_string(),
582            when: None,
583            response: ResponseSpec::Sequence { sequence: vec![] },
584        };
585        let err = Router::new(vec![r]).unwrap_err();
586        assert!(matches!(err, RouterError::EmptySequence { route_index: 0 }));
587    }
588}