Skip to main content

churust_core/
router.rs

1//! The trie-based [`Router`], its [`Match`] result, and the [`RouteBuilder`]
2//! DSL used inside [`AppBuilder::routing`](crate::AppBuilder::routing).
3//!
4//! Routes are matched segment by segment against static text, `{param}`
5//! captures, and a trailing `{name...}` wildcard. Static segments win over
6//! parameters, which win over wildcards.
7
8use crate::handler::{boxed, BoxHandler, IntoHandler};
9use http::Method;
10use std::collections::HashMap;
11
12/// The outcome of routing a `(method, path)` pair against the [`Router`].
13///
14/// Returned by [`Router::route`]. The framework translates each variant into a
15/// response: [`Found`](Match::Found) runs the handler,
16/// [`MethodNotAllowed`](Match::MethodNotAllowed) yields `405` with an `Allow`
17/// header, and [`NotFound`](Match::NotFound) yields `404`.
18///
19/// ```
20/// use churust_core::{boxed, Call, IntoHandler, Router, Match};
21/// use http::Method;
22///
23/// let mut router = Router::new();
24/// router.add(Method::GET, "/users/{id}", boxed((|_c: Call| async { "ok" }).into_handler()));
25///
26/// match router.route(&Method::GET, "/users/7") {
27///     Match::Found { params, .. } => assert_eq!(params.get("id").unwrap(), "7"),
28///     _ => panic!("expected a match"),
29/// }
30/// assert!(matches!(router.route(&Method::GET, "/nope"), Match::NotFound));
31/// assert!(matches!(router.route(&Method::POST, "/users/7"), Match::MethodNotAllowed { .. }));
32/// ```
33pub enum Match {
34    /// A handler matched the path and method. Carries the matched handler and
35    /// the captured path parameters (`{name}` -> value).
36    Found {
37        /// The handler registered for this `(path, method)`.
38        handler: BoxHandler,
39        /// The captured path parameters, keyed by name.
40        params: HashMap<String, String>,
41    },
42    /// The path matched a route, but not for this method. `allow` lists the
43    /// methods that *are* registered (used to build the `Allow` header).
44    MethodNotAllowed {
45        /// The methods registered for the matched path.
46        allow: Vec<Method>,
47    },
48    /// No route matched the path at all.
49    NotFound,
50}
51
52#[derive(Default)]
53struct Node {
54    statics: HashMap<String, Node>,
55    param: Option<(String, Box<Node>)>,      // {name}
56    wildcard: Option<(String, BoxHandlers)>, // {name...} terminal
57    handlers: BoxHandlers,
58}
59
60#[derive(Default)]
61struct BoxHandlers(HashMap<Method, BoxHandler>);
62
63impl std::fmt::Debug for Node {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("Node").finish_non_exhaustive()
66    }
67}
68
69/// A compiled, trie-based router mapping `(method, path)` to a handler.
70///
71/// Build one with [`Router::new`], register routes with [`Router::add`], and
72/// look them up with [`Router::route`]. Inside an application you usually do not
73/// touch the `Router` directly — the
74/// [`RouteBuilder`] DSL in
75/// [`AppBuilder::routing`](crate::AppBuilder::routing) populates it for you.
76///
77/// Supported pattern syntax (full paths, leading `/`):
78/// - static segments: `/users/list`
79/// - named parameters: `/users/{id}` (captured as `id`)
80/// - trailing wildcard: `/files/{path...}` (captures the remaining path,
81///   slashes included; must be the last segment)
82///
83/// ```
84/// use churust_core::{boxed, Call, IntoHandler, Router, Match};
85/// use http::Method;
86///
87/// let mut router = Router::new();
88/// router.add(Method::GET, "/files/{path...}", boxed((|_c: Call| async { "" }).into_handler()));
89/// match router.route(&Method::GET, "/files/a/b/c.txt") {
90///     Match::Found { params, .. } => assert_eq!(params.get("path").unwrap(), "a/b/c.txt"),
91///     _ => panic!("expected wildcard match"),
92/// }
93/// ```
94#[derive(Debug, Default)]
95pub struct Router {
96    root: Node,
97}
98
99impl Router {
100    /// Create an empty router. Equivalent to [`Router::default`].
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Insert `handler` for `method` at `pattern` (the full path, e.g.
106    /// `/users/{id}`).
107    ///
108    /// Registering different methods at the same path is fine; registering the
109    /// same `(method, path)` twice replaces the earlier handler.
110    ///
111    /// # Panics
112    ///
113    /// Panics if a `{name...}` wildcard segment is not the final segment of the
114    /// pattern.
115    ///
116    /// ```
117    /// use churust_core::{boxed, Call, IntoHandler, Router, Match};
118    /// use http::Method;
119    ///
120    /// let mut router = Router::new();
121    /// router.add(Method::GET, "/ping", boxed((|_c: Call| async { "pong" }).into_handler()));
122    /// assert!(matches!(router.route(&Method::GET, "/ping"), Match::Found { .. }));
123    /// ```
124    pub fn add(&mut self, method: Method, pattern: &str, handler: BoxHandler) {
125        let mut node = &mut self.root;
126        let segments: Vec<&str> = split_segments(pattern);
127        for (i, seg) in segments.iter().enumerate() {
128            if let Some(name) = seg.strip_prefix('{').and_then(|s| s.strip_suffix("...}")) {
129                // wildcard must be terminal
130                assert!(
131                    i == segments.len() - 1,
132                    "wildcard `{{{name}...}}` must be last segment"
133                );
134                let entry = node
135                    .wildcard
136                    .get_or_insert_with(|| (name.to_string(), BoxHandlers::default()));
137                entry.1 .0.insert(method, handler);
138                return;
139            } else if let Some(name) = seg.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
140                let entry = node
141                    .param
142                    .get_or_insert_with(|| (name.to_string(), Box::new(Node::default())));
143                node = entry.1.as_mut();
144            } else {
145                node = node.statics.entry(seg.to_string()).or_default();
146            }
147        }
148        node.handlers.0.insert(method, handler);
149    }
150
151    /// Route `path` for `method`, returning a [`Match`].
152    ///
153    /// Matching prefers static segments over `{param}` captures, and falls back
154    /// to a `{name...}` wildcard at the deepest matchable ancestor. A path that
155    /// matches a node with no handler for `method` yields
156    /// [`Match::MethodNotAllowed`]; a path that matches no node yields
157    /// [`Match::NotFound`].
158    ///
159    /// ```
160    /// use churust_core::{boxed, Call, IntoHandler, Router, Match};
161    /// use http::Method;
162    ///
163    /// let mut router = Router::new();
164    /// router.add(Method::GET, "/", boxed((|_c: Call| async { "home" }).into_handler()));
165    /// assert!(matches!(router.route(&Method::GET, "/"), Match::Found { .. }));
166    /// assert!(matches!(router.route(&Method::GET, "/missing"), Match::NotFound));
167    /// ```
168    pub fn route(&self, method: &Method, path: &str) -> Match {
169        let segments = split_segments(path);
170        let mut params = HashMap::new();
171        match Self::walk(&self.root, &segments, 0, &mut params) {
172            Some(node) => match node.handlers.0.get(method) {
173                Some(h) => Match::Found {
174                    handler: h.clone(),
175                    params,
176                },
177                None if node.handlers.0.is_empty() => Match::NotFound,
178                None => Match::MethodNotAllowed {
179                    allow: node.handlers.0.keys().cloned().collect(),
180                },
181            },
182            None => {
183                // try wildcard at the deepest matchable ancestor
184                if let Some(m) = Self::walk_wildcard(&self.root, &segments, 0, method, &mut params)
185                {
186                    m
187                } else {
188                    Match::NotFound
189                }
190            }
191        }
192    }
193
194    fn walk<'a>(
195        node: &'a Node,
196        segs: &[&str],
197        i: usize,
198        params: &mut HashMap<String, String>,
199    ) -> Option<&'a Node> {
200        if i == segs.len() {
201            return Some(node);
202        }
203        let seg = segs[i];
204        if let Some(child) = node.statics.get(seg) {
205            if let Some(n) = Self::walk(child, segs, i + 1, params) {
206                return Some(n);
207            }
208        }
209        if let Some((name, child)) = &node.param {
210            params.insert(name.clone(), seg.to_string());
211            if let Some(n) = Self::walk(child, segs, i + 1, params) {
212                return Some(n);
213            }
214            params.remove(name);
215        }
216        None
217    }
218
219    fn walk_wildcard(
220        node: &Node,
221        segs: &[&str],
222        i: usize,
223        method: &Method,
224        params: &mut HashMap<String, String>,
225    ) -> Option<Match> {
226        if let Some((name, handlers)) = &node.wildcard {
227            let rest = segs[i..].join("/");
228            params.insert(name.clone(), rest);
229            return Some(match handlers.0.get(method) {
230                Some(h) => Match::Found {
231                    handler: h.clone(),
232                    params: std::mem::take(params),
233                },
234                None => Match::MethodNotAllowed {
235                    allow: handlers.0.keys().cloned().collect(),
236                },
237            });
238        }
239        if i < segs.len() {
240            if let Some(child) = node.statics.get(segs[i]) {
241                if let Some(m) = Self::walk_wildcard(child, segs, i + 1, method, params) {
242                    return Some(m);
243                }
244            }
245            if let Some((pname, child)) = &node.param {
246                params.insert(pname.clone(), segs[i].to_string());
247                if let Some(m) = Self::walk_wildcard(child, segs, i + 1, method, params) {
248                    return Some(m);
249                }
250                params.remove(pname);
251            }
252        }
253        None
254    }
255}
256
257fn split_segments(path: &str) -> Vec<&str> {
258    path.split('/').filter(|s| !s.is_empty()).collect()
259}
260
261/// The route-definition DSL handed to the closure in
262/// [`AppBuilder::routing`](crate::AppBuilder::routing).
263///
264/// Register handlers with the per-method helpers ([`get`](RouteBuilder::get),
265/// [`post`](RouteBuilder::post), [`put`](RouteBuilder::put),
266/// [`delete`](RouteBuilder::delete)) or the generic [`method`](RouteBuilder::method).
267/// Group related routes under a common prefix with [`route`](RouteBuilder::route),
268/// which nests cleanly. Each handler may be an extractor closure or anything
269/// implementing [`Handler`](crate::Handler).
270///
271/// ```
272/// use churust_core::{Churust, Call, TestClient};
273/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
274/// let app = Churust::server()
275///     .routing(|r| {
276///         r.get("/", |_c: Call| async { "home" });
277///         r.route("/api", |r| {
278///             r.get("/health", |_c: Call| async { "ok" });
279///         });
280///     })
281///     .build();
282/// let res = TestClient::new(app).get("/api/health").send().await;
283/// assert_eq!(res.text(), "ok");
284/// # });
285/// ```
286pub struct RouteBuilder<'r> {
287    router: &'r mut Router,
288    prefix: String,
289}
290
291impl<'r> RouteBuilder<'r> {
292    pub(crate) fn new(router: &'r mut Router) -> Self {
293        Self {
294            router,
295            prefix: String::new(),
296        }
297    }
298
299    fn full(&self, path: &str) -> String {
300        let mut p = self.prefix.clone();
301        if !path.starts_with('/') {
302            p.push('/');
303        }
304        p.push_str(path);
305        p
306    }
307
308    /// Register a handler for `method` at `path`. Accepts both extractor
309    /// closures (via the `HandlerFn` family) and anything already implementing
310    /// `Handler` — including a pre-boxed `BoxHandler` — through `IntoHandler`.
311    pub fn method<Marker, H>(&mut self, method: Method, path: &str, handler: H) -> &mut Self
312    where
313        H: IntoHandler<Marker>,
314    {
315        let full = self.full(path);
316        self.router
317            .add(method, &full, boxed(handler.into_handler()));
318        self
319    }
320
321    /// Register a `GET` handler at `path`. Returns `&mut Self` for chaining.
322    pub fn get<Marker, H>(&mut self, path: &str, handler: H) -> &mut Self
323    where
324        H: IntoHandler<Marker>,
325    {
326        self.method(Method::GET, path, handler)
327    }
328    /// Register a `POST` handler at `path`. Returns `&mut Self` for chaining.
329    pub fn post<Marker, H>(&mut self, path: &str, handler: H) -> &mut Self
330    where
331        H: IntoHandler<Marker>,
332    {
333        self.method(Method::POST, path, handler)
334    }
335    /// Register a `PUT` handler at `path`. Returns `&mut Self` for chaining.
336    pub fn put<Marker, H>(&mut self, path: &str, handler: H) -> &mut Self
337    where
338        H: IntoHandler<Marker>,
339    {
340        self.method(Method::PUT, path, handler)
341    }
342    /// Register a `DELETE` handler at `path`. Returns `&mut Self` for chaining.
343    pub fn delete<Marker, H>(&mut self, path: &str, handler: H) -> &mut Self
344    where
345        H: IntoHandler<Marker>,
346    {
347        self.method(Method::DELETE, path, handler)
348    }
349
350    /// Open a nested scope: every route registered inside `f` has `path`
351    /// prepended to its pattern. Scopes nest arbitrarily, so prefixes compose.
352    /// Returns `&mut Self` for chaining.
353    pub fn route(&mut self, path: &str, f: impl FnOnce(&mut RouteBuilder)) -> &mut Self {
354        let prefix = self.full(path);
355        let mut child = RouteBuilder {
356            router: self.router,
357            prefix,
358        };
359        f(&mut child);
360        self
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::call::Call;
368    use bytes::Bytes;
369    use http::{HeaderMap, StatusCode, Uri};
370
371    fn build() -> Router {
372        let mut r = Router::new();
373        {
374            let mut b = RouteBuilder::new(&mut r);
375            b.get("/", |_c: Call| async { "root" });
376            b.route("/users", |b| {
377                b.get("/{id}", |c: Call| async move {
378                    format!("user {}", c.param_raw("id").unwrap())
379                });
380                b.post("/", |_c: Call| async { (StatusCode::CREATED, "created") });
381            });
382            b.get("/files/{path...}", |c: Call| async move {
383                format!("file {}", c.param_raw("path").unwrap())
384            });
385        }
386        r
387    }
388
389    fn run(r: &Router, m: Method, path: &str) -> Match {
390        r.route(&m, path)
391    }
392
393    #[tokio::test]
394    async fn matches_static_and_param() {
395        let r = build();
396        match run(&r, Method::GET, "/users/7") {
397            Match::Found { handler, params } => {
398                assert_eq!(params.get("id").unwrap(), "7");
399                let mut c = Call::new(
400                    Method::GET,
401                    "/users/7".parse::<Uri>().unwrap(),
402                    HeaderMap::new(),
403                    Bytes::new(),
404                );
405                c.set_params(params);
406                let res = handler.handle(c).await;
407                assert_eq!(res.body, Bytes::from("user 7"));
408            }
409            _ => panic!("expected Found"),
410        }
411    }
412
413    #[test]
414    fn unknown_path_is_not_found() {
415        let r = build();
416        assert!(matches!(run(&r, Method::GET, "/nope"), Match::NotFound));
417    }
418
419    #[test]
420    fn known_path_wrong_method_is_405() {
421        let r = build();
422        match run(&r, Method::DELETE, "/users/7") {
423            Match::MethodNotAllowed { allow } => assert!(allow.contains(&Method::GET)),
424            _ => panic!("expected 405"),
425        }
426    }
427
428    #[test]
429    fn wildcard_captures_rest() {
430        let r = build();
431        match run(&r, Method::GET, "/files/a/b/c.txt") {
432            Match::Found { params, .. } => assert_eq!(params.get("path").unwrap(), "a/b/c.txt"),
433            _ => panic!("expected wildcard Found"),
434        }
435    }
436
437    // Regression guard for blocking issue #2: a pre-boxed `BoxHandler` must be
438    // acceptable to the route builder methods, not only closures.
439    #[tokio::test]
440    async fn route_builder_accepts_boxed_handler() {
441        let pre: BoxHandler = boxed((|_c: Call| async { "pre-boxed" }).into_handler());
442        let mut r = Router::new();
443        {
444            let mut b = RouteBuilder::new(&mut r);
445            b.get("/pre", pre);
446        }
447        match run(&r, Method::GET, "/pre") {
448            Match::Found { handler, .. } => {
449                let c = Call::new(
450                    Method::GET,
451                    "/pre".parse::<Uri>().unwrap(),
452                    HeaderMap::new(),
453                    Bytes::new(),
454                );
455                let res = handler.handle(c).await;
456                assert_eq!(res.body, Bytes::from("pre-boxed"));
457            }
458            _ => panic!("expected Found"),
459        }
460    }
461}