Skip to main content

jerrycan_core/
router.rs

1//! Method routing + segment trie with `{param}` captures (spec §4.1).
2//! Conflicting routes are detected at build time — fail loud before serving.
3//! Path segments are percent-decoded after '/'-splitting; malformed encodings
4//! surface as `RouteMatch::Malformed` (a clean 400, never a panic).
5
6use crate::dep::DepEnv;
7use crate::error::{Error, Result};
8use crate::handler::{BoxHandlerFn, Handler};
9use crate::middleware::Middleware;
10use http::Method;
11use std::collections::HashMap;
12use std::sync::Arc;
13
14/// Per-path method table: `get(list).post(create)` (spec §4.1).
15pub struct MethodRouter {
16    pub(crate) handlers: Vec<(Method, BoxHandlerFn)>,
17    /// Per-route request-body cap in bytes. `None` defers to the app default
18    /// (1 MiB, spec §4.4). Applies to ALL methods on the route, not per-method.
19    pub(crate) body_limit: Option<usize>,
20    /// When true, the body is NOT buffered before dispatch — extractors read
21    /// the live stream lane. Applies to ALL methods on the route.
22    pub(crate) stream_body: bool,
23    /// Per-route override of the app-global handler-time budget (issue #111).
24    /// `None` defers to the app default. Applies to ALL methods on the route.
25    pub(crate) handler_timeout: Option<std::time::Duration>,
26    /// Per-route override of the app-global per-frame body-read deadline (#111).
27    /// `None` defers to the app default. Applies to ALL methods on the route.
28    pub(crate) body_read_timeout: Option<std::time::Duration>,
29}
30
31pub fn get<H: Handler<A>, A>(h: H) -> MethodRouter {
32    MethodRouter::new().on(Method::GET, h)
33}
34pub fn post<H: Handler<A>, A>(h: H) -> MethodRouter {
35    MethodRouter::new().on(Method::POST, h)
36}
37pub fn put<H: Handler<A>, A>(h: H) -> MethodRouter {
38    MethodRouter::new().on(Method::PUT, h)
39}
40pub fn patch<H: Handler<A>, A>(h: H) -> MethodRouter {
41    MethodRouter::new().on(Method::PATCH, h)
42}
43pub fn delete<H: Handler<A>, A>(h: H) -> MethodRouter {
44    MethodRouter::new().on(Method::DELETE, h)
45}
46
47impl MethodRouter {
48    fn new() -> Self {
49        Self {
50            handlers: Vec::new(),
51            body_limit: None,
52            stream_body: false,
53            handler_timeout: None,
54            body_read_timeout: None,
55        }
56    }
57
58    pub fn on<H: Handler<A>, A>(mut self, method: Method, h: H) -> Self {
59        self.handlers.push((method, h.into_handler_fn()));
60        self
61    }
62
63    /// Cap the request body for THIS route at `bytes`, overriding the app's
64    /// 1 MiB default (spec §4.4). The cap is per-route — it applies to every
65    /// method registered here, not per-method. Bodies over the cap are
66    /// rejected with 413 before the handler runs.
67    pub fn body_limit(mut self, bytes: usize) -> Self {
68        self.body_limit = Some(bytes);
69        self
70    }
71
72    /// Marks every method on this route as STREAMING: the body is not buffered
73    /// before dispatch; extractors read it incrementally (Multipart) or drain it
74    /// on demand (Json/RawBody). `body_limit` still caps cumulative bytes.
75    pub fn stream_body(mut self) -> Self {
76        self.stream_body = true;
77        self
78    }
79
80    /// Override the app-global handler-time budget for THIS route (issue #111).
81    /// A slow-but-moving upload on a `.stream_body()` route drains inside the
82    /// handler, so raise this (not the app-global in tool-owned main.rs — that
83    /// trips JL0003) to give the drain room. `None` ⇒ the app default applies.
84    pub fn handler_timeout(mut self, budget: std::time::Duration) -> Self {
85        self.handler_timeout = Some(budget);
86        self
87    }
88
89    /// Override the app-global per-frame body-read deadline for THIS route (#111).
90    pub fn body_read_timeout(mut self, budget: std::time::Duration) -> Self {
91        self.body_read_timeout = Some(budget);
92        self
93    }
94
95    pub fn get<H: Handler<A>, A>(self, h: H) -> Self {
96        self.on(Method::GET, h)
97    }
98    pub fn post<H: Handler<A>, A>(self, h: H) -> Self {
99        self.on(Method::POST, h)
100    }
101    pub fn put<H: Handler<A>, A>(self, h: H) -> Self {
102        self.on(Method::PUT, h)
103    }
104    pub fn patch<H: Handler<A>, A>(self, h: H) -> Self {
105        self.on(Method::PATCH, h)
106    }
107    pub fn delete<H: Handler<A>, A>(self, h: H) -> Self {
108        self.on(Method::DELETE, h)
109    }
110}
111
112/// A flattened route: method table + the effective dependency environment and
113/// middleware chain for this path (computed at build time, spec §4.2).
114pub(crate) struct Endpoint {
115    pub(crate) methods: HashMap<Method, BoxHandlerFn>,
116    pub(crate) env: Arc<DepEnv>,
117    pub(crate) middleware: Arc<[Arc<dyn Middleware>]>,
118    /// Per-route body cap (bytes); `None` = the app default. Read pre-dispatch
119    /// by `route_policy` to size the body read for this route (spec §4.4).
120    pub(crate) body_limit: Option<usize>,
121    /// When true, the body is streamed (not collected upfront) — `route_policy`
122    /// reports it so serve hands the live stream lane to dispatch (v2.1).
123    pub(crate) stream_body: bool,
124    /// Per-route handler-time budget (#111); `None` = the app default. Resolved
125    /// against the app-global at the `dispatch` timeout wrap.
126    pub(crate) handler_timeout: Option<std::time::Duration>,
127    /// Per-route per-frame body-read deadline (#111); `None` = the app default.
128    /// Resolved against the app-global where `route_policy` sizes the lane.
129    pub(crate) body_read_timeout: Option<std::time::Duration>,
130}
131
132#[derive(Default)]
133pub(crate) struct Trie {
134    root: Node,
135}
136
137#[derive(Default)]
138struct Node {
139    statics: HashMap<String, Node>,
140    param: Option<(String, Box<Node>)>,
141    endpoint: Option<Endpoint>,
142}
143
144pub(crate) enum RouteMatch<'a> {
145    Found {
146        endpoint: &'a Endpoint,
147        params: Vec<(String, String)>,
148    },
149    MethodMissing,
150    Malformed,
151    NotFound,
152}
153
154fn segments(path: &str) -> impl Iterator<Item = &str> {
155    path.split('/').filter(|s| !s.is_empty())
156}
157
158/// Decode %XX sequences in ONE path segment. `None` = malformed (bad hex,
159/// truncated escape, or non-UTF-8 result) — the caller answers 400.
160/// Runs after '/'-splitting, so an encoded slash cannot create segments.
161fn decode_segment(seg: &str) -> Option<String> {
162    if !seg.contains('%') {
163        return Some(seg.to_string());
164    }
165    fn hex(b: u8) -> Option<u8> {
166        match b {
167            b'0'..=b'9' => Some(b - b'0'),
168            b'a'..=b'f' => Some(b - b'a' + 10),
169            b'A'..=b'F' => Some(b - b'A' + 10),
170            _ => None,
171        }
172    }
173    let bytes = seg.as_bytes();
174    let mut out = Vec::with_capacity(bytes.len());
175    let mut i = 0;
176    while i < bytes.len() {
177        if bytes[i] == b'%' {
178            // `get` returns None on truncated escapes; `hex` on bad digits.
179            let high = hex(*bytes.get(i + 1)?)?;
180            let low = hex(*bytes.get(i + 2)?)?;
181            out.push(high * 16 + low);
182            i += 3;
183        } else {
184            out.push(bytes[i]);
185            i += 1;
186        }
187    }
188    String::from_utf8(out).ok()
189}
190
191impl Trie {
192    // NOTE: the design validator mirrors this walk line-for-line
193    // (jerrycan/src/platform/questions.rs `router_param_conflict`, JC0542) so
194    // param conflicts are caught at design time instead of panicking here. If
195    // the segment/param semantics below change, update the twin in lockstep.
196    pub(crate) fn insert(&mut self, path: &str, endpoint: Endpoint) -> Result<()> {
197        let mut node = &mut self.root;
198        for seg in segments(path) {
199            if let Some(name) = seg.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
200                if node.param.is_none() {
201                    node.param = Some((name.to_string(), Box::default()));
202                }
203                let (existing, child) = node.param.as_mut().expect("just ensured");
204                if existing != name {
205                    return Err(Error::internal(format!(
206                        "conflicting path parameters `{{{existing}}}` vs `{{{name}}}` in `{path}`"
207                    )));
208                }
209                node = child;
210            } else {
211                node = node.statics.entry(seg.to_string()).or_default();
212            }
213        }
214        if node.endpoint.is_some() {
215            return Err(Error::internal(format!(
216                "duplicate route registration for `{path}`"
217            )));
218        }
219        node.endpoint = Some(endpoint);
220        Ok(())
221    }
222
223    pub(crate) fn find<'a>(&'a self, path: &str, method: &Method) -> RouteMatch<'a> {
224        if !path.contains('%') {
225            let segs: Vec<&str> = segments(path).collect();
226            return self.find_in(&segs, method);
227        }
228        let mut decoded: Vec<String> = Vec::new();
229        for raw in segments(path) {
230            match decode_segment(raw) {
231                Some(d) => decoded.push(d),
232                None => return RouteMatch::Malformed,
233            }
234        }
235        let segs: Vec<&str> = decoded.iter().map(String::as_str).collect();
236        self.find_in(&segs, method)
237    }
238
239    /// The HTTP methods registered for `path`, or `None` if the path is unknown.
240    /// Used by CORS preflight to reflect `Access-Control-Allow-Methods`. The walk
241    /// mirrors [`Trie::find`] (percent-decode then resolve via `find_node`) but
242    /// ignores the request method — preflight cares only whether the path exists.
243    /// Methods are sorted so the emitted header is deterministic (a framework
244    /// invariant) regardless of registration order.
245    pub(crate) fn methods_for(&self, path: &str) -> Option<Vec<Method>> {
246        let mut params: Vec<(String, String)> = Vec::new();
247        let node = if path.contains('%') {
248            let mut decoded: Vec<String> = Vec::new();
249            for raw in segments(path) {
250                decoded.push(decode_segment(raw)?);
251            }
252            let segs: Vec<&str> = decoded.iter().map(String::as_str).collect();
253            find_node(&self.root, &segs, &mut params)
254        } else {
255            let segs: Vec<&str> = segments(path).collect();
256            find_node(&self.root, &segs, &mut params)
257        }?;
258        let ep = node
259            .endpoint
260            .as_ref()
261            .expect("find_node only returns endpoint nodes");
262        let mut methods: Vec<Method> = ep.methods.keys().cloned().collect();
263        methods.sort_by(|a, b| a.as_str().cmp(b.as_str()));
264        Some(methods)
265    }
266
267    fn find_in<'a>(&'a self, segs: &[&str], method: &Method) -> RouteMatch<'a> {
268        let mut params: Vec<(String, String)> = Vec::new();
269        match find_node(&self.root, segs, &mut params) {
270            Some(node) => {
271                let ep = node
272                    .endpoint
273                    .as_ref()
274                    .expect("find_node only returns endpoint nodes");
275                if ep.methods.contains_key(method) {
276                    RouteMatch::Found {
277                        endpoint: ep,
278                        params,
279                    }
280                } else {
281                    RouteMatch::MethodMissing
282                }
283            }
284            None => RouteMatch::NotFound,
285        }
286    }
287}
288
289/// Depth-first with backtracking: static child first; if that subtree fails,
290/// retry via the param child (capturing the segment). Only nodes WITH an
291/// endpoint count as matches, so a static dead-end falls back to the param route.
292fn find_node<'a>(
293    node: &'a Node,
294    segs: &[&str],
295    params: &mut Vec<(String, String)>,
296) -> Option<&'a Node> {
297    let Some((head, rest)) = segs.split_first() else {
298        return node.endpoint.is_some().then_some(node);
299    };
300    if let Some(child) = node.statics.get(*head)
301        && let Some(found) = find_node(child, rest, params)
302    {
303        return Some(found);
304    }
305    if let Some((name, child)) = &node.param {
306        params.push((name.clone(), (*head).to_string()));
307        if let Some(found) = find_node(child, rest, params) {
308            return Some(found);
309        }
310        params.pop();
311    }
312    None
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::response::IntoResponse;
319
320    fn dummy_handler() -> BoxHandlerFn {
321        Arc::new(move |_ctx: &mut crate::RequestCtx| Box::pin(async move { "ok".into_response() }))
322    }
323
324    fn endpoint(methods: &[Method]) -> Endpoint {
325        let mut map = HashMap::new();
326        for m in methods {
327            map.insert(m.clone(), dummy_handler());
328        }
329        Endpoint {
330            methods: map,
331            env: Arc::new(DepEnv::default()),
332            middleware: Arc::from(vec![]),
333            body_limit: None,
334            stream_body: false,
335            handler_timeout: None,
336            body_read_timeout: None,
337        }
338    }
339
340    #[test]
341    fn static_and_param_segments_match() {
342        let mut t = Trie::default();
343        t.insert("/todos", endpoint(&[Method::GET])).unwrap();
344        t.insert("/todos/{id}", endpoint(&[Method::GET, Method::DELETE]))
345            .unwrap();
346        t.insert("/todos/{id}/comments", endpoint(&[Method::GET]))
347            .unwrap();
348
349        match t.find("/todos/42/comments", &Method::GET) {
350            RouteMatch::Found { params, .. } => {
351                assert_eq!(params, vec![("id".to_string(), "42".to_string())])
352            }
353            _ => panic!("expected match"),
354        }
355        assert!(matches!(
356            t.find("/todos/42", &Method::DELETE),
357            RouteMatch::Found { .. }
358        ));
359    }
360
361    #[test]
362    fn unknown_path_is_not_found_and_wrong_method_is_method_missing() {
363        let mut t = Trie::default();
364        t.insert("/todos", endpoint(&[Method::GET])).unwrap();
365        assert!(matches!(
366            t.find("/nope", &Method::GET),
367            RouteMatch::NotFound
368        ));
369        assert!(matches!(
370            t.find("/todos", &Method::POST),
371            RouteMatch::MethodMissing
372        ));
373    }
374
375    #[test]
376    fn duplicate_path_registration_is_a_build_error() {
377        let mut t = Trie::default();
378        t.insert("/todos", endpoint(&[Method::GET])).unwrap();
379        let err = t.insert("/todos", endpoint(&[Method::POST])).unwrap_err();
380        assert!(err.message().contains("/todos"));
381    }
382
383    #[test]
384    fn conflicting_param_names_are_a_build_error() {
385        let mut t = Trie::default();
386        t.insert("/todos/{id}", endpoint(&[Method::GET])).unwrap();
387        let err = t
388            .insert("/todos/{todo_id}", endpoint(&[Method::DELETE]))
389            .unwrap_err();
390        assert!(err.message().contains("id"));
391    }
392
393    #[test]
394    fn static_dead_end_backtracks_to_param_branch() {
395        let mut t = Trie::default();
396        t.insert("/a/b/c", endpoint(&[Method::GET])).unwrap();
397        t.insert("/a/{x}/d", endpoint(&[Method::GET])).unwrap();
398        match t.find("/a/b/d", &Method::GET) {
399            RouteMatch::Found { params, .. } => {
400                assert_eq!(params, vec![("x".to_string(), "b".to_string())]);
401            }
402            _ => panic!("expected /a/{{x}}/d to match /a/b/d via backtracking"),
403        }
404        assert!(matches!(
405            t.find("/a/b/c", &Method::GET),
406            RouteMatch::Found { .. }
407        ));
408    }
409
410    #[test]
411    fn static_wins_over_param_when_both_match() {
412        let mut t = Trie::default();
413        t.insert("/users/me", endpoint(&[Method::GET])).unwrap();
414        t.insert("/users/{id}", endpoint(&[Method::GET])).unwrap();
415        match t.find("/users/me", &Method::GET) {
416            RouteMatch::Found { params, .. } => {
417                assert!(params.is_empty(), "static match captures nothing")
418            }
419            _ => panic!("expected static /users/me"),
420        }
421        match t.find("/users/42", &Method::GET) {
422            RouteMatch::Found { params, .. } => {
423                assert_eq!(params, vec![("id".to_string(), "42".to_string())])
424            }
425            _ => panic!("expected param /users/{{id}}"),
426        }
427    }
428
429    #[test]
430    fn method_router_builder_collects_methods() {
431        let mr = get(|| async { "a" }).post(|| async { "b" });
432        let methods: Vec<_> = mr.handlers.iter().map(|(m, _)| m.clone()).collect();
433        assert_eq!(methods, vec![Method::GET, Method::POST]);
434    }
435
436    #[test]
437    fn percent_encoded_segments_decode_for_statics_and_params() {
438        let mut t = Trie::default();
439        t.insert("/caf\u{e9}/menu", endpoint(&[Method::GET]))
440            .unwrap();
441        t.insert("/todos/{id}", endpoint(&[Method::GET])).unwrap();
442
443        // %C3%A9 = é in a STATIC segment
444        assert!(matches!(
445            t.find("/caf%C3%A9/menu", &Method::GET),
446            RouteMatch::Found { .. }
447        ));
448
449        // %2F decodes INSIDE the param value without creating a new segment
450        match t.find("/todos/a%2Fb", &Method::GET) {
451            RouteMatch::Found { params, .. } => assert_eq!(params[0].1, "a/b"),
452            other => panic!(
453                "expected param capture, got no match ({})",
454                matches!(other, RouteMatch::NotFound)
455            ),
456        }
457
458        // %20 decodes to a space
459        match t.find("/todos/hello%20world", &Method::GET) {
460            RouteMatch::Found { params, .. } => assert_eq!(params[0].1, "hello world"),
461            _ => panic!("expected match"),
462        }
463    }
464
465    #[test]
466    fn malformed_percent_encodings_are_flagged_not_matched() {
467        let mut t = Trie::default();
468        t.insert("/todos/{id}", endpoint(&[Method::GET])).unwrap();
469        assert!(matches!(
470            t.find("/todos/%zz", &Method::GET),
471            RouteMatch::Malformed
472        ));
473        assert!(matches!(
474            t.find("/todos/%2", &Method::GET),
475            RouteMatch::Malformed
476        )); // truncated
477        assert!(matches!(
478            t.find("/todos/%FF", &Method::GET),
479            RouteMatch::Malformed
480        )); // invalid UTF-8
481    }
482}