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}