Skip to main content

rustlavel_http/
router.rs

1//! The router: what an application's `routes/web.rs` fills in.
2//!
3//! ```ignore
4//! pub fn routes(r: &mut Router) {
5//!     r.get("/", home);
6//!     r.get("/users/{id}", show).name("users.show");
7//!
8//!     r.group("/admin", |r| {
9//!         r.middleware(auth);
10//!         r.get("/dashboard", dashboard);
11//!     });
12//! }
13//! ```
14
15use crate::handler::Handler;
16use crate::method::Method;
17use crate::middleware::{Middleware, Next};
18use crate::request::Request;
19use crate::response::Response;
20use crate::status::Status;
21use crate::url;
22use std::collections::BTreeMap;
23use std::sync::Arc;
24
25/// One piece of a route pattern.
26#[derive(Debug, Clone, PartialEq, Eq)]
27enum Segment {
28    /// A literal path segment.
29    Static(String),
30    /// `{id}` — matches exactly one segment and captures it.
31    Param(String),
32    /// `{path:*}` — matches the rest of the path, slashes included.
33    Wildcard(String),
34}
35
36pub struct Route {
37    pub method: Method,
38    /// The pattern as written, used for `route:list` and metrics labels.
39    pub pattern: String,
40    pub name: Option<String>,
41    /// What this route does, in one line. Feeds generated API documentation.
42    pub summary: Option<String>,
43    /// A grouping label, so generated docs are not one flat list.
44    pub tag: Option<String>,
45    /// Documented responses: status, and what it means.
46    pub responses: Vec<(u16, String)>,
47    /// Documented parameters: name, and what it is.
48    pub parameters: Vec<(String, String)>,
49    pub deprecated: bool,
50    /// The API version this route belongs to, from [`Router::version`].
51    pub version: Option<String>,
52    /// When the route was deprecated (unix time), sent as `Deprecation`.
53    pub deprecated_at: Option<i64>,
54    /// When the route will be removed (unix time), sent as `Sunset`.
55    pub sunset: Option<i64>,
56    segments: Vec<Segment>,
57    handler: Arc<dyn Handler>,
58    middleware: Arc<Vec<Arc<dyn Middleware>>>,
59}
60
61impl Route {
62    /// The parameter names this route captures, in order.
63    ///
64    /// Generated documentation needs these even when the author documented
65    /// none, because a path parameter is required whether or not it is
66    /// described.
67    pub fn parameter_names(&self) -> Vec<String> {
68        self.segments
69            .iter()
70            .filter_map(|segment| match segment {
71                Segment::Param(name) | Segment::Wildcard(name) => Some(name.clone()),
72                Segment::Static(_) => None,
73            })
74            .collect()
75    }
76}
77
78impl Route {
79    /// How specific this route is, so `/users/new` is tried before `/users/{id}`.
80    fn specificity(&self) -> (usize, usize) {
81        let statics = self.segments.iter().filter(|s| matches!(s, Segment::Static(_))).count();
82        let wildcards = self.segments.iter().filter(|s| matches!(s, Segment::Wildcard(_))).count();
83        // More static segments first; any wildcard sinks to the bottom.
84        (wildcards, usize::MAX - statics)
85    }
86
87    fn match_path(&self, path: &str) -> Option<BTreeMap<String, String>> {
88        let mut params = BTreeMap::new();
89        let mut parts = split_path(path);
90        let mut index = 0;
91
92        while index < self.segments.len() {
93            match &self.segments[index] {
94                Segment::Wildcard(name) => {
95                    // Consumes everything that is left, including nothing.
96                    let rest = parts.collect::<Vec<_>>().join("/");
97                    params.insert(name.clone(), url::decode(&rest));
98                    return Some(params);
99                }
100                Segment::Static(expected) => {
101                    if parts.next()? != expected {
102                        return None;
103                    }
104                }
105                Segment::Param(name) => {
106                    let value = parts.next()?;
107                    if value.is_empty() {
108                        return None;
109                    }
110                    params.insert(name.clone(), url::decode(value));
111                }
112            }
113            index += 1;
114        }
115
116        // Every pattern segment matched; the path must be exhausted too.
117        parts.next().is_none().then_some(params)
118    }
119}
120
121fn split_path(path: &str) -> impl Iterator<Item = &str> {
122    path.split('/').filter(|part| !part.is_empty())
123}
124
125fn parse_pattern(pattern: &str) -> Vec<Segment> {
126    split_path(pattern)
127        .map(|part| match part.strip_prefix('{').and_then(|p| p.strip_suffix('}')) {
128            Some(name) => match name.strip_suffix(":*") {
129                Some(name) => Segment::Wildcard(name.to_string()),
130                None => Segment::Param(name.to_string()),
131            },
132            None => Segment::Static(part.to_string()),
133        })
134        .collect()
135}
136
137/// Collects routes, then answers requests.
138#[derive(Default)]
139pub struct Router {
140    routes: Vec<Route>,
141    /// Prefix and middleware of the group currently being defined.
142    scope_prefix: String,
143    scope_version: Option<String>,
144    scope_middleware: Vec<Arc<dyn Middleware>>,
145    /// Runs for every request, whatever the route.
146    global_middleware: Vec<Arc<dyn Middleware>>,
147    fallback: Option<Arc<dyn Handler>>,
148}
149
150impl Router {
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    /// Add middleware. Inside a `group` it applies to that group's routes;
156    /// at the top level it applies to every request, including 404s.
157    pub fn middleware(&mut self, middleware: impl Middleware) -> &mut Self {
158        if self.scope_prefix.is_empty() && self.scope_middleware.is_empty() {
159            self.global_middleware.push(Arc::new(middleware));
160        } else {
161            self.scope_middleware.push(Arc::new(middleware));
162        }
163        self
164    }
165
166    /// Register routes under a shared prefix and middleware stack.
167    pub fn group(&mut self, prefix: &str, define: impl FnOnce(&mut Router)) -> &mut Self {
168        let mut child = Router {
169            scope_prefix: join_paths(&self.scope_prefix, prefix),
170            scope_middleware: self.scope_middleware.clone(),
171            scope_version: self.scope_version.clone(),
172            ..Router::default()
173        };
174        define(&mut child);
175
176        // A group's global-looking middleware belongs to that group only.
177        debug_assert!(child.global_middleware.is_empty() || !child.routes.is_empty());
178        self.routes.extend(child.routes);
179        self
180    }
181
182    /// Register one version of an API: a group under `/{version}` whose routes
183    /// know which version they are.
184    ///
185    /// ```ignore
186    /// r.version("v1", |v1| { v1.get("/users", v1::users::index); });
187    /// r.version("v2", |v2| { v2.get("/users", v2::users::index); });
188    /// ```
189    ///
190    /// A handler can read the version with `req.api_version()`, which lets one
191    /// handler serve two versions where the difference is small, and generated
192    /// documentation groups routes by it. Versioning by header instead of path
193    /// is [`crate::versioning::VersionHeader`].
194    pub fn version(&mut self, version: &str, define: impl FnOnce(&mut Router)) -> &mut Self {
195        let mut child = Router {
196            scope_prefix: join_paths(&self.scope_prefix, &format!("/{}", version.trim_start_matches('/'))),
197            scope_middleware: self.scope_middleware.clone(),
198            scope_version: Some(version.trim_start_matches('/').to_string()),
199            ..Router::default()
200        };
201        define(&mut child);
202        self.routes.extend(child.routes);
203        self
204    }
205
206    /// The response when nothing matched. Defaults to a plain 404.
207    pub fn fallback(&mut self, handler: impl Handler) -> &mut Self {
208        self.fallback = Some(Arc::new(handler));
209        self
210    }
211
212    pub fn route(&mut self, method: Method, pattern: &str, handler: impl Handler) -> RouteHandle<'_> {
213        let full = join_paths(&self.scope_prefix, pattern);
214        self.routes.push(Route {
215            method,
216            segments: parse_pattern(&full),
217            pattern: full,
218            name: None,
219            summary: None,
220            tag: None,
221            responses: Vec::new(),
222            parameters: Vec::new(),
223            deprecated: false,
224            version: self.scope_version.clone(),
225            deprecated_at: None,
226            sunset: None,
227            handler: Arc::new(handler),
228            middleware: Arc::new(self.scope_middleware.clone()),
229        });
230        RouteHandle { index: self.routes.len() - 1, router: self }
231    }
232
233    pub fn get(&mut self, pattern: &str, handler: impl Handler) -> RouteHandle<'_> {
234        self.route(Method::Get, pattern, handler)
235    }
236
237    pub fn post(&mut self, pattern: &str, handler: impl Handler) -> RouteHandle<'_> {
238        self.route(Method::Post, pattern, handler)
239    }
240
241    pub fn put(&mut self, pattern: &str, handler: impl Handler) -> RouteHandle<'_> {
242        self.route(Method::Put, pattern, handler)
243    }
244
245    pub fn patch(&mut self, pattern: &str, handler: impl Handler) -> RouteHandle<'_> {
246        self.route(Method::Patch, pattern, handler)
247    }
248
249    pub fn delete(&mut self, pattern: &str, handler: impl Handler) -> RouteHandle<'_> {
250        self.route(Method::Delete, pattern, handler)
251    }
252
253    /// Start a RESTful resource: `r.resource("/posts").index(..).show(..)`.
254    pub fn resource<'r>(&'r mut self, base: &str) -> Resource<'r> {
255        Resource { base: base.trim_end_matches('/').to_string(), router: self }
256    }
257
258    /// Sort routes so lookups are deterministic. Called once before serving.
259    pub fn finalize(&mut self) {
260        self.routes.sort_by_key(Route::specificity);
261    }
262
263    pub fn routes(&self) -> &[Route] {
264        &self.routes
265    }
266
267    /// Build a URL from a named route: `url_for("users.show", &[("id", "7")])`.
268    pub fn url_for(&self, name: &str, params: &[(&str, &str)]) -> Option<String> {
269        let route = self.routes.iter().find(|route| route.name.as_deref() == Some(name))?;
270        let lookup = |key: &str| params.iter().find(|(k, _)| *k == key).map(|(_, v)| *v);
271
272        let mut out = String::new();
273        for segment in &route.segments {
274            out.push('/');
275            match segment {
276                Segment::Static(value) => out.push_str(value),
277                Segment::Param(name) => out.push_str(&url::encode(lookup(name)?)),
278                Segment::Wildcard(name) => out.push_str(lookup(name)?),
279            }
280        }
281        Some(if out.is_empty() { "/".to_string() } else { out })
282    }
283
284    /// Match a request and run it through the pipeline.
285    pub async fn dispatch(&self, mut request: Request) -> Response {
286        let path = request.path().to_string();
287        let mut path_matched = false;
288        let mut allowed = Vec::new();
289
290        for route in &self.routes {
291            let Some(params) = route.match_path(&path) else { continue };
292            path_matched = true;
293
294            // HEAD is served by the GET route, minus the body.
295            let usable = route.method == request.method()
296                || (request.method() == Method::Head && route.method == Method::Get);
297
298            if !usable {
299                allowed.push(route.method);
300                continue;
301            }
302
303            request.set_params(params);
304            request.route = Some(route.pattern.clone());
305            if let Some(version) = &route.version {
306                request.extend(crate::versioning::ApiVersion(version.clone()));
307            }
308
309            let mut stack = self.global_middleware.clone();
310            stack.extend(route.middleware.iter().cloned());
311            let response =
312                run_guarded(Next::new(Arc::new(stack), Arc::clone(&route.handler)), request).await;
313            return crate::versioning::stamp_lifecycle(route, response);
314        }
315
316        let response = if path_matched {
317            // The path exists but not for this verb: 405, and say what is allowed.
318            allowed.sort();
319            allowed.dedup();
320            let allow = allowed.iter().map(|m| m.as_str()).collect::<Vec<_>>().join(", ");
321            Response::new(Status::METHOD_NOT_ALLOWED).with_header("allow", allow).with_text(format!(
322                "{} is not allowed on {path}",
323                request.method()
324            ))
325        } else {
326            match &self.fallback {
327                Some(handler) => {
328                    let stack = Arc::new(self.global_middleware.clone());
329                    return Next::new(stack, Arc::clone(handler)).run(request).await;
330                }
331                None => Response::not_found(),
332            }
333        };
334
335        // Global middleware still observes unmatched requests, so logging and
336        // Telescope see the 404s too.
337        let stack = Arc::new(self.global_middleware.clone());
338        let endpoint: Arc<dyn Handler> = Arc::new(crate::handler::Fixed(response));
339        Next::new(stack, endpoint).run(request).await
340    }
341}
342
343/// Run the pipeline, turning a panic into the error page.
344///
345/// This lives at the router rather than in the server so a panicking handler
346/// fails the same way in a test as it does in production — a test that would
347/// otherwise abort the whole run instead reports a 500.
348async fn run_guarded(next: Next, request: Request) -> Response {
349    crate::panic::install_hook();
350    let started = std::time::Instant::now();
351
352    // A panicking handler still needs to render a page describing the request,
353    // but the request itself has been moved into the pipeline by then.
354    let probe = Request::new(request.method(), request.target().to_string())
355        .with_header("accept", request.header("accept").unwrap_or("text/html"));
356    let route = request.route().map(str::to_string);
357
358    let response = match crate::panic::catch(next.run(request)).await {
359        Ok(response) => response,
360        Err(message) => {
361            let location = crate::panic::take_location().map(|l| (l.file, l.line));
362            rustlavel_core::error!(
363                "panic in {} {}: {message}",
364                probe.method(),
365                probe.path()
366            );
367            crate::error_page::render(
368                &crate::error_page::Diagnostic::from_panic(message, location),
369                Some(&probe),
370            )
371        }
372    };
373
374    // Dispatched here rather than in the server, so instrumentation sees the
375    // same events under the test client as it does over a socket.
376    if rustlavel_core::events::has_subscribers() {
377        let mut event = rustlavel_core::Event::new("http.request")
378            .with("method", probe.method().as_str())
379            .with("path", probe.path())
380            .with("status", response.status.code())
381            .took(started.elapsed());
382        if let Some(route) = route {
383            // The pattern, not the path: one metric series per route, not per id.
384            event = event.with("route", route);
385        }
386        // Read from the response rather than the request, which the pipeline
387        // has consumed by now; the middleware puts it there for exactly this.
388        if let Some(id) = response.headers.get(crate::request_id::HEADER) {
389            event = event.with("request_id", id);
390        }
391        event.dispatch();
392    }
393
394    response
395}
396
397/// Returned by `get`/`post`/… so a route can be named after registration.
398pub struct RouteHandle<'r> {
399    index: usize,
400    router: &'r mut Router,
401}
402
403impl RouteHandle<'_> {
404    /// Name the route for `url_for` and `route:list`.
405    pub fn name(self, name: &str) -> Self {
406        self.router.routes[self.index].name = Some(name.to_string());
407        self
408    }
409
410    /// Say what this route does, in one line.
411    ///
412    /// Documentation is attached here rather than kept in a separate file, so
413    /// it cannot drift away from the route it describes.
414    pub fn describe(self, summary: &str) -> Self {
415        self.router.routes[self.index].summary = Some(summary.to_string());
416        self
417    }
418
419    /// Group this route under a heading in generated documentation.
420    pub fn tag(self, tag: &str) -> Self {
421        self.router.routes[self.index].tag = Some(tag.to_string());
422        self
423    }
424
425    /// Document a response this route can return.
426    pub fn responds(self, status: u16, description: &str) -> Self {
427        self.router.routes[self.index].responses.push((status, description.to_string()));
428        self
429    }
430
431    /// Describe a parameter. Undescribed path parameters are still documented,
432    /// just without prose.
433    pub fn param(self, name: &str, description: &str) -> Self {
434        self.router.routes[self.index]
435            .parameters
436            .push((name.to_string(), description.to_string()));
437        self
438    }
439
440    /// Mark the route as deprecated in generated documentation.
441    pub fn deprecated(self) -> Self {
442        self.router.routes[self.index].deprecated = true;
443        self
444    }
445
446    /// Say when the route was deprecated, as `YYYY-MM-DD`.
447    ///
448    /// Responses then carry `Deprecation: @<unix time>` (RFC 9745), which is
449    /// how a client library learns to warn its own developers. Implies
450    /// [`RouteHandle::deprecated`].
451    ///
452    /// # Panics
453    ///
454    /// On a date that is not `YYYY-MM-DD` — this is called at startup, with a
455    /// literal, and a typo should fail there rather than send garbage.
456    pub fn deprecated_at(self, date: &str) -> Self {
457        let when = crate::date::parse_ymd(date)
458            .unwrap_or_else(|| panic!("`{date}` is not a date; deprecated_at wants YYYY-MM-DD"));
459        let route = &mut self.router.routes[self.index];
460        route.deprecated = true;
461        route.deprecated_at = Some(when);
462        self
463    }
464
465    /// Say when the route will stop working, as `YYYY-MM-DD`.
466    ///
467    /// Responses then carry `Sunset` (RFC 8594) with that date, and the route
468    /// is marked deprecated. Nothing removes the route on the day — that is a
469    /// deploy, and a person's decision — but every client has been told.
470    ///
471    /// # Panics
472    ///
473    /// On a date that is not `YYYY-MM-DD`, for the reason given on
474    /// [`RouteHandle::deprecated_at`].
475    pub fn sunset(self, date: &str) -> Self {
476        let when = crate::date::parse_ymd(date)
477            .unwrap_or_else(|| panic!("`{date}` is not a date; sunset wants YYYY-MM-DD"));
478        let route = &mut self.router.routes[self.index];
479        route.deprecated = true;
480        route.sunset = Some(when);
481        self
482    }
483}
484
485/// The seven RESTful routes, registered one at a time.
486pub struct Resource<'r> {
487    base: String,
488    router: &'r mut Router,
489}
490
491impl Resource<'_> {
492    /// `GET /posts`
493    pub fn index(self, handler: impl Handler) -> Self {
494        let (base, router) = (self.base.clone(), self.router);
495        router.get(&base, handler).name(&format!("{}.index", resource_name(&base)));
496        Resource { base, router }
497    }
498
499    /// `POST /posts`
500    pub fn store(self, handler: impl Handler) -> Self {
501        let (base, router) = (self.base.clone(), self.router);
502        router.post(&base, handler).name(&format!("{}.store", resource_name(&base)));
503        Resource { base, router }
504    }
505
506    /// `GET /posts/{id}`
507    pub fn show(self, handler: impl Handler) -> Self {
508        let (base, router) = (self.base.clone(), self.router);
509        let pattern = format!("{base}/{{id}}");
510        router.get(&pattern, handler).name(&format!("{}.show", resource_name(&base)));
511        Resource { base, router }
512    }
513
514    /// `PUT /posts/{id}`
515    pub fn update(self, handler: impl Handler) -> Self {
516        let (base, router) = (self.base.clone(), self.router);
517        let pattern = format!("{base}/{{id}}");
518        router.put(&pattern, handler).name(&format!("{}.update", resource_name(&base)));
519        Resource { base, router }
520    }
521
522    /// `DELETE /posts/{id}`
523    pub fn destroy(self, handler: impl Handler) -> Self {
524        let (base, router) = (self.base.clone(), self.router);
525        let pattern = format!("{base}/{{id}}");
526        router.delete(&pattern, handler).name(&format!("{}.destroy", resource_name(&base)));
527        Resource { base, router }
528    }
529}
530
531fn resource_name(base: &str) -> String {
532    base.trim_matches('/').replace('/', ".")
533}
534
535fn join_paths(prefix: &str, path: &str) -> String {
536    let joined = format!("/{}/{}", prefix.trim_matches('/'), path.trim_matches('/'));
537    let cleaned = joined.replace("//", "/");
538    if cleaned.len() > 1 { cleaned.trim_end_matches('/').to_string() } else { "/".to_string() }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    async fn ok(_req: Request) -> &'static str {
546        "ok"
547    }
548
549    fn router_with(define: impl FnOnce(&mut Router)) -> Router {
550        let mut router = Router::new();
551        define(&mut router);
552        router.finalize();
553        router
554    }
555
556    #[tokio::test]
557    async fn matches_static_and_parameter_routes() {
558        let router = router_with(|r| {
559            r.get("/", ok);
560            r.get("/users/{id}", |req: Request| async move {
561                format!("user {}", req.param("id").unwrap())
562            });
563        });
564
565        assert_eq!(router.dispatch(Request::new(Method::Get, "/")).await.body_string(), "ok");
566        assert_eq!(
567            router.dispatch(Request::new(Method::Get, "/users/7")).await.body_string(),
568            "user 7"
569        );
570        assert_eq!(
571            router.dispatch(Request::new(Method::Get, "/nope")).await.status,
572            Status::NOT_FOUND
573        );
574    }
575
576    #[tokio::test]
577    async fn static_segments_win_over_parameters() {
578        let router = router_with(|r| {
579            r.get("/users/{id}", |_req: Request| async { "param" });
580            r.get("/users/new", |_req: Request| async { "static" });
581        });
582
583        assert_eq!(
584            router.dispatch(Request::new(Method::Get, "/users/new")).await.body_string(),
585            "static"
586        );
587        assert_eq!(
588            router.dispatch(Request::new(Method::Get, "/users/12")).await.body_string(),
589            "param"
590        );
591    }
592
593    #[tokio::test]
594    async fn wildcards_capture_the_remaining_path() {
595        let router = router_with(|r| {
596            r.get("/files/{path:*}", |req: Request| async move {
597                req.param("path").unwrap_or_default().to_string()
598            });
599        });
600
601        let response = router.dispatch(Request::new(Method::Get, "/files/css/app.css")).await;
602        assert_eq!(response.body_string(), "css/app.css");
603    }
604
605    #[tokio::test]
606    async fn parameters_are_percent_decoded() {
607        let router = router_with(|r| {
608            r.get("/tags/{tag}", |req: Request| async move { req.param("tag").unwrap().to_string() });
609        });
610
611        let response = router.dispatch(Request::new(Method::Get, "/tags/rust%20lang")).await;
612        assert_eq!(response.body_string(), "rust lang");
613    }
614
615    #[tokio::test]
616    async fn wrong_method_reports_405_with_allow() {
617        let router = router_with(|r| {
618            r.post("/users", ok);
619        });
620
621        let response = router.dispatch(Request::new(Method::Get, "/users")).await;
622        assert_eq!(response.status, Status::METHOD_NOT_ALLOWED);
623        assert_eq!(response.headers.get("allow"), Some("POST"));
624    }
625
626    #[tokio::test]
627    async fn head_is_served_by_the_get_route() {
628        let router = router_with(|r| {
629            r.get("/", ok);
630        });
631
632        assert_eq!(router.dispatch(Request::new(Method::Head, "/")).await.status, Status::OK);
633    }
634
635    #[tokio::test]
636    async fn groups_apply_prefix_and_middleware() {
637        let router = router_with(|r| {
638            r.group("/admin", |r| {
639                r.middleware(|req: Request, next: Next| async move {
640                    next.run(req).await.with_header("x-guard", "on")
641                });
642                r.get("/dashboard", ok);
643            });
644            r.get("/public", ok);
645        });
646
647        let guarded = router.dispatch(Request::new(Method::Get, "/admin/dashboard")).await;
648        assert_eq!(guarded.headers.get("x-guard"), Some("on"));
649
650        let open = router.dispatch(Request::new(Method::Get, "/public")).await;
651        assert_eq!(open.headers.get("x-guard"), None);
652    }
653
654    #[tokio::test]
655    async fn global_middleware_also_sees_unmatched_requests() {
656        let router = router_with(|r| {
657            r.middleware(|req: Request, next: Next| async move {
658                next.run(req).await.with_header("x-seen", "1")
659            });
660            r.get("/", ok);
661        });
662
663        let missing = router.dispatch(Request::new(Method::Get, "/nope")).await;
664        assert_eq!(missing.status, Status::NOT_FOUND);
665        assert_eq!(missing.headers.get("x-seen"), Some("1"));
666    }
667
668    #[test]
669    fn builds_urls_from_named_routes() {
670        let router = router_with(|r| {
671            r.get("/users/{id}/posts/{slug}", ok).name("users.posts");
672        });
673
674        assert_eq!(
675            router.url_for("users.posts", &[("id", "7"), ("slug", "hello world")]).as_deref(),
676            Some("/users/7/posts/hello%20world")
677        );
678        assert_eq!(router.url_for("users.posts", &[("id", "7")]), None);
679        assert_eq!(router.url_for("missing", &[]), None);
680    }
681
682    #[tokio::test]
683    async fn resource_registers_the_rest_routes() {
684        let router = router_with(|r| {
685            r.resource("/posts").index(ok).store(ok).show(ok).update(ok).destroy(ok);
686        });
687
688        assert_eq!(router.routes().len(), 5);
689        assert_eq!(router.url_for("posts.show", &[("id", "3")]).as_deref(), Some("/posts/3"));
690        assert_eq!(router.dispatch(Request::new(Method::Delete, "/posts/3")).await.status, Status::OK);
691    }
692
693    #[tokio::test]
694    async fn fallback_replaces_the_default_404() {
695        let router = router_with(|r| {
696            r.fallback(|_req: Request| async { (404, "custom miss") });
697        });
698
699        let response = router.dispatch(Request::new(Method::Get, "/anything")).await;
700        assert_eq!(response.body_string(), "custom miss");
701    }
702
703    #[test]
704    fn documentation_rides_along_with_the_route() {
705        let router = router_with(|r| {
706            r.get("/users/{id}", ok)
707                .name("users.show")
708                .describe("Fetch one user")
709                .tag("Users")
710                .param("id", "The user's id")
711                .responds(200, "The user")
712                .responds(404, "No such user");
713        });
714
715        let route = &router.routes()[0];
716        assert_eq!(route.summary.as_deref(), Some("Fetch one user"));
717        assert_eq!(route.tag.as_deref(), Some("Users"));
718        assert_eq!(route.responses.len(), 2);
719        assert_eq!(route.parameter_names(), vec!["id"]);
720        assert!(!route.deprecated);
721    }
722
723    #[test]
724    fn path_parameters_are_known_even_when_undocumented() {
725        let router = router_with(|r| {
726            r.get("/teams/{team}/members/{member}", ok);
727            r.get("/files/{path:*}", ok);
728        });
729
730        let names: Vec<Vec<String>> =
731            router.routes().iter().map(Route::parameter_names).collect();
732        assert!(names.contains(&vec!["team".to_string(), "member".to_string()]));
733        assert!(names.contains(&vec!["path".to_string()]));
734    }
735
736    #[test]
737    fn joins_prefixes_without_doubling_slashes() {
738        assert_eq!(join_paths("/admin/", "/users"), "/admin/users");
739        assert_eq!(join_paths("", "/"), "/");
740        assert_eq!(join_paths("/admin", ""), "/admin");
741    }
742}