Skip to main content

ferro_rs/routing/
group.rs

1//! Route grouping with shared prefix and middleware
2
3use super::path::combine_group_path;
4use super::{BoxedHandler, RouteBuilder, Router};
5use crate::http::{Request, Response};
6use crate::middleware::{into_boxed, BoxedMiddleware, Middleware};
7use std::future::Future;
8use std::sync::Arc;
9
10/// Builder for route groups with shared prefix and middleware
11///
12/// # Example
13///
14/// ```rust,ignore
15/// Router::new()
16///     .group("/api", |r| {
17///         r.get("/users", list_users)
18///          .post("/users", create_user)
19///     }).middleware(ApiMiddleware)
20/// ```
21pub struct GroupBuilder {
22    /// The outer router we're building into
23    outer_router: Router,
24    /// Routes registered within this group (stored as full paths)
25    group_routes: Vec<GroupRoute>,
26    /// The prefix for this group
27    prefix: String,
28    /// Middleware to apply to all routes in this group
29    middleware: Vec<BoxedMiddleware>,
30}
31
32/// A route registered within a group
33struct GroupRoute {
34    method: GroupMethod,
35    path: String,
36    handler: Arc<BoxedHandler>,
37}
38
39#[derive(Clone, Copy)]
40enum GroupMethod {
41    Get,
42    Post,
43    Put,
44    Patch,
45    Delete,
46}
47
48impl GroupBuilder {
49    /// Apply middleware to all routes in this group
50    ///
51    /// # Example
52    ///
53    /// ```rust,ignore
54    /// Router::new()
55    ///     .group("/api", |r| r.get("/users", handler))
56    ///     .middleware(ApiMiddleware)
57    /// ```
58    pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
59        self.middleware.push(into_boxed(middleware));
60        self
61    }
62
63    /// Finalize the group and merge routes into the outer router.
64    ///
65    /// Uses `combine_group_path` to compute the canonical registration path
66    /// (and an optional trailing-slash alternate), so `r.get("/", h)` inside
67    /// a non-root group reaches `h` at both `/prefix` and `/prefix/`. The
68    /// alternate matchit leaf stores the canonical pattern string, which is
69    /// what `server.rs` uses as the middleware-lookup key — so one
70    /// `add_middleware(&canonical, ...)` call covers both variants
71    /// (Strategy A).
72    fn finalize(mut self) -> Router {
73        for route in self.group_routes {
74            let (canonical, alternate) = combine_group_path(&self.prefix, &route.path);
75
76            match route.method {
77                GroupMethod::Get => {
78                    self.outer_router
79                        .insert_get(&canonical, route.handler.clone());
80                    if let Some(alt) = alternate.as_deref() {
81                        self.outer_router
82                            .insert_get_alias(alt, route.handler, &canonical);
83                    }
84                }
85                GroupMethod::Post => {
86                    self.outer_router
87                        .insert_post(&canonical, route.handler.clone());
88                    if let Some(alt) = alternate.as_deref() {
89                        self.outer_router
90                            .insert_post_alias(alt, route.handler, &canonical);
91                    }
92                }
93                GroupMethod::Put => {
94                    self.outer_router
95                        .insert_put(&canonical, route.handler.clone());
96                    if let Some(alt) = alternate.as_deref() {
97                        self.outer_router
98                            .insert_put_alias(alt, route.handler, &canonical);
99                    }
100                }
101                GroupMethod::Patch => {
102                    self.outer_router
103                        .insert_patch(&canonical, route.handler.clone());
104                    if let Some(alt) = alternate.as_deref() {
105                        self.outer_router
106                            .insert_patch_alias(alt, route.handler, &canonical);
107                    }
108                }
109                GroupMethod::Delete => {
110                    self.outer_router
111                        .insert_delete(&canonical, route.handler.clone());
112                    if let Some(alt) = alternate.as_deref() {
113                        self.outer_router
114                            .insert_delete_alias(alt, route.handler, &canonical);
115                    }
116                }
117            }
118
119            // Strategy A: add middleware under the canonical key only — the
120            // alias leaf's matchit value stores the canonical pattern string,
121            // so dispatch resolves middleware under this same key regardless
122            // of which URL variant the request hit.
123            for mw in &self.middleware {
124                self.outer_router.add_middleware(&canonical, mw.clone());
125            }
126        }
127
128        self.outer_router
129    }
130}
131
132/// Inner router used within a group closure
133///
134/// This captures routes without a prefix, which are later merged with the group's prefix.
135pub struct GroupRouter {
136    routes: Vec<GroupRoute>,
137}
138
139impl GroupRouter {
140    fn new() -> Self {
141        Self { routes: Vec::new() }
142    }
143
144    /// Register a GET route within the group
145    pub fn get<H, Fut>(mut self, path: &str, handler: H) -> Self
146    where
147        H: Fn(Request) -> Fut + Send + Sync + 'static,
148        Fut: Future<Output = Response> + Send + 'static,
149    {
150        let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
151        self.routes.push(GroupRoute {
152            method: GroupMethod::Get,
153            path: path.to_string(),
154            handler: Arc::new(boxed),
155        });
156        self
157    }
158
159    /// Register a POST route within the group
160    pub fn post<H, Fut>(mut self, path: &str, handler: H) -> Self
161    where
162        H: Fn(Request) -> Fut + Send + Sync + 'static,
163        Fut: Future<Output = Response> + Send + 'static,
164    {
165        let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
166        self.routes.push(GroupRoute {
167            method: GroupMethod::Post,
168            path: path.to_string(),
169            handler: Arc::new(boxed),
170        });
171        self
172    }
173
174    /// Register a PUT route within the group
175    pub fn put<H, Fut>(mut self, path: &str, handler: H) -> Self
176    where
177        H: Fn(Request) -> Fut + Send + Sync + 'static,
178        Fut: Future<Output = Response> + Send + 'static,
179    {
180        let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
181        self.routes.push(GroupRoute {
182            method: GroupMethod::Put,
183            path: path.to_string(),
184            handler: Arc::new(boxed),
185        });
186        self
187    }
188
189    /// Register a PATCH route within the group
190    pub fn patch<H, Fut>(mut self, path: &str, handler: H) -> Self
191    where
192        H: Fn(Request) -> Fut + Send + Sync + 'static,
193        Fut: Future<Output = Response> + Send + 'static,
194    {
195        let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
196        self.routes.push(GroupRoute {
197            method: GroupMethod::Patch,
198            path: path.to_string(),
199            handler: Arc::new(boxed),
200        });
201        self
202    }
203
204    /// Register a DELETE route within the group
205    pub fn delete<H, Fut>(mut self, path: &str, handler: H) -> Self
206    where
207        H: Fn(Request) -> Fut + Send + Sync + 'static,
208        Fut: Future<Output = Response> + Send + 'static,
209    {
210        let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
211        self.routes.push(GroupRoute {
212            method: GroupMethod::Delete,
213            path: path.to_string(),
214            handler: Arc::new(boxed),
215        });
216        self
217    }
218}
219
220impl Router {
221    /// Create a route group with a shared prefix
222    ///
223    /// Routes defined within the group will have the prefix prepended to their paths.
224    /// Middleware applied to the group will be applied to all routes within it.
225    ///
226    /// # Example
227    ///
228    /// ```rust,ignore
229    /// Router::new()
230    ///     .group("/api", |r| {
231    ///         r.get("/users", list_users)      // -> GET /api/users
232    ///          .post("/users", create_user)    // -> POST /api/users
233    ///          .get("/users/{id}", show_user)  // -> GET /api/users/{id}
234    ///     })
235    ///     .middleware(ApiMiddleware)
236    /// ```
237    pub fn group<F>(self, prefix: &str, builder_fn: F) -> GroupBuilder
238    where
239        F: FnOnce(GroupRouter) -> GroupRouter,
240    {
241        let inner = GroupRouter::new();
242        let built = builder_fn(inner);
243
244        GroupBuilder {
245            outer_router: self,
246            group_routes: built.routes,
247            prefix: prefix.to_string(),
248            middleware: Vec::new(),
249        }
250    }
251}
252
253impl From<GroupBuilder> for Router {
254    fn from(builder: GroupBuilder) -> Self {
255        builder.finalize()
256    }
257}
258
259// Allow RouteBuilder to chain into groups
260impl RouteBuilder {
261    /// Create a route group with a shared prefix
262    pub fn group<F>(self, prefix: &str, builder_fn: F) -> GroupBuilder
263    where
264        F: FnOnce(GroupRouter) -> GroupRouter,
265    {
266        self.router.group(prefix, builder_fn)
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::routing::get_registered_routes;
274    use hyper::Method;
275
276    async fn test_handler(_req: Request) -> Response {
277        crate::http::text("ok")
278    }
279
280    #[test]
281    fn builder_group_root_handler_matches_both_variants() {
282        // D-01: Router::new().group("/api", |r| r.get("/", h)) reaches h at
283        // both /api and /api/. Uses unique prefix to avoid global registry
284        // collision with parallel tests.
285        let router: Router = Router::new()
286            .group("/api-b01", |r| r.get("/", test_handler))
287            .into();
288
289        // D-07: exactly one RouteInfo entry (alias does not call register_route)
290        let routes = get_registered_routes();
291        let count = routes.iter().filter(|r| r.path == "/api-b01").count();
292        assert_eq!(
293            count, 1,
294            "expected exactly 1 RouteInfo entry for /api-b01, got {count}"
295        );
296
297        // Canonical leaf
298        let hit_canonical = router.match_route(&Method::GET, "/api-b01");
299        assert!(hit_canonical.is_some(), "canonical /api-b01 did not match");
300        assert_eq!(hit_canonical.unwrap().2, "/api-b01");
301
302        // Alternate leaf must carry canonical pattern (Strategy A)
303        let hit_alternate = router.match_route(&Method::GET, "/api-b01/");
304        assert!(hit_alternate.is_some(), "alternate /api-b01/ did not match");
305        assert_eq!(
306            hit_alternate.unwrap().2,
307            "/api-b01",
308            "alternate leaf must carry canonical pattern for middleware lookup"
309        );
310    }
311
312    #[test]
313    fn builder_root_prefix_root_handler_is_single_slash() {
314        // D-02: group "/" with route "/" → exactly one "/" leaf, no "//" alternate.
315        let router: Router = Router::new()
316            .group("/", |r| r.get("/", test_handler))
317            .into();
318
319        let hit = router.match_route(&Method::GET, "/");
320        assert!(hit.is_some(), "/ did not match");
321        assert_eq!(hit.unwrap().2, "/");
322
323        let double = router.match_route(&Method::GET, "//");
324        assert!(
325            double.is_none(),
326            "// must not be registered for root-in-root group"
327        );
328    }
329
330    #[test]
331    fn builder_trailing_slash_prefix_is_stripped() {
332        // D-03: prefix "/api-b03/" with route "/x" → /api-b03/x; no /api-b03//x
333        let router: Router = Router::new()
334            .group("/api-b03/", |r| r.get("/x", test_handler))
335            .into();
336
337        assert!(
338            router.match_route(&Method::GET, "/api-b03/x").is_some(),
339            "/api-b03/x did not match after trailing-slash strip"
340        );
341        assert!(
342            router.match_route(&Method::GET, "/api-b03//x").is_none(),
343            "unexpected match for /api-b03//x — trailing slash not stripped"
344        );
345    }
346
347    #[test]
348    fn builder_non_root_prefix_non_root_path_unchanged() {
349        // D-04 regression: non-root route path must not emit a trailing-slash alternate.
350        let router: Router = Router::new()
351            .group("/api-b04", |r| r.get("/users", test_handler))
352            .into();
353
354        let routes = get_registered_routes();
355        let count = routes.iter().filter(|r| r.path == "/api-b04/users").count();
356        assert_eq!(
357            count, 1,
358            "expected exactly 1 RouteInfo for /api-b04/users, got {count}"
359        );
360
361        assert!(
362            router.match_route(&Method::GET, "/api-b04/users").is_some(),
363            "/api-b04/users did not match"
364        );
365        assert!(
366            router
367                .match_route(&Method::GET, "/api-b04/users/")
368                .is_none(),
369            "non-root leaf must not emit an alternate"
370        );
371    }
372
373    #[test]
374    fn builder_middleware_registered_under_canonical_only() {
375        // Strategy A registry-level assertion: add_middleware is called with the
376        // canonical key only; the alias leaf has no separate HashMap entry.
377        // (The integration test in Plan 04 proves both URL variants actually
378        // execute the middleware at dispatch time.)
379        let router: Router = Router::new()
380            .group("/api-b05", |r| r.get("/", test_handler))
381            .into();
382
383        // Group has no middleware — both lookups should be empty vecs.
384        // Structural assertion: the lookup API works and returns the correct
385        // (empty) vec; no panics; no key confusion.
386        assert!(
387            router.get_route_middleware("/api-b05").is_empty(),
388            "canonical key must return empty vec (no middleware registered)"
389        );
390        assert!(
391            router.get_route_middleware("/api-b05/").is_empty(),
392            "alias key must return empty vec (Strategy A: no separate entry)"
393        );
394    }
395
396    #[test]
397    fn builder_post_and_put_and_patch_and_delete_aliases_reach_handler() {
398        // Verify all four non-GET verbs emit canonical + alias pairs when
399        // route path is "/".
400        let router_post: Router = Router::new()
401            .group("/api-b06p", |r| r.post("/", test_handler))
402            .into();
403        assert!(
404            router_post
405                .match_route(&Method::POST, "/api-b06p")
406                .is_some(),
407            "POST /api-b06p did not match"
408        );
409        assert!(
410            router_post
411                .match_route(&Method::POST, "/api-b06p/")
412                .is_some(),
413            "POST /api-b06p/ did not match"
414        );
415
416        let router_put: Router = Router::new()
417            .group("/api-b06u", |r| r.put("/", test_handler))
418            .into();
419        assert!(
420            router_put.match_route(&Method::PUT, "/api-b06u").is_some(),
421            "PUT /api-b06u did not match"
422        );
423        assert!(
424            router_put.match_route(&Method::PUT, "/api-b06u/").is_some(),
425            "PUT /api-b06u/ did not match"
426        );
427
428        let router_patch: Router = Router::new()
429            .group("/api-b06a", |r| r.patch("/", test_handler))
430            .into();
431        assert!(
432            router_patch
433                .match_route(&Method::PATCH, "/api-b06a")
434                .is_some(),
435            "PATCH /api-b06a did not match"
436        );
437        assert!(
438            router_patch
439                .match_route(&Method::PATCH, "/api-b06a/")
440                .is_some(),
441            "PATCH /api-b06a/ did not match"
442        );
443
444        let router_delete: Router = Router::new()
445            .group("/api-b06d", |r| r.delete("/", test_handler))
446            .into();
447        assert!(
448            router_delete
449                .match_route(&Method::DELETE, "/api-b06d")
450                .is_some(),
451            "DELETE /api-b06d did not match"
452        );
453        assert!(
454            router_delete
455                .match_route(&Method::DELETE, "/api-b06d/")
456                .is_some(),
457            "DELETE /api-b06d/ did not match"
458        );
459    }
460}