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