ferro_rs/routing/macros.rs
1//! Route definition macros and helpers for Laravel-like routing syntax
2//!
3//! This module provides a clean, declarative way to define routes:
4//!
5//! ```rust,ignore
6//! use ferro_rs::{routes, get, post, put, delete, group};
7//!
8//! routes! {
9//! get!("/", controllers::home::index).name("home"),
10//! get!("/users", controllers::user::index).name("users.index"),
11//! post!("/users", controllers::user::store).name("users.store"),
12//! get!("/protected", controllers::home::index).middleware(AuthMiddleware),
13//!
14//! // Route groups with prefix and middleware
15//! group!("/api", {
16//! get!("/users", controllers::api::user::index).name("api.users.index"),
17//! post!("/users", controllers::api::user::store).name("api.users.store"),
18//! }).middleware(AuthMiddleware),
19//! }
20//! ```
21
22use crate::http::{Request, Response};
23
24/// Const function to validate route paths start with '/'
25///
26/// This provides compile-time validation that all route paths begin with '/'.
27/// If a path doesn't start with '/', compilation will fail with a clear error.
28///
29/// # Panics
30///
31/// Panics at compile time if the path is empty or doesn't start with '/'.
32pub const fn validate_route_path(path: &'static str) -> &'static str {
33 let bytes = path.as_bytes();
34 if bytes.is_empty() || bytes[0] != b'/' {
35 panic!("Route path must start with '/'")
36 }
37 path
38}
39use super::path::combine_group_path;
40use super::router::update_route_mcp;
41use crate::middleware::{into_boxed, BoxedMiddleware, Middleware};
42use crate::routing::router::{register_route_name, BoxedHandler, Router};
43use std::future::Future;
44use std::sync::Arc;
45
46/// Convert Express-style `:param` route parameters to matchit-style `{param}`
47///
48/// This allows developers to use either syntax:
49/// - `/:id` (Express/Rails style)
50/// - `/{id}` (matchit native style)
51///
52/// # Examples
53///
54/// - `/users/:id` → `/users/{id}`
55/// - `/posts/:post_id/comments/:id` → `/posts/{post_id}/comments/{id}`
56/// - `/users/{id}` → `/users/{id}` (already correct syntax, unchanged)
57fn convert_route_params(path: &str) -> String {
58 let mut result = String::with_capacity(path.len() + 4); // Extra space for braces
59 let mut chars = path.chars().peekable();
60
61 while let Some(ch) = chars.next() {
62 if ch == ':' {
63 // Start of parameter - collect until '/' or end
64 result.push('{');
65 while let Some(&next) = chars.peek() {
66 if next == '/' {
67 break;
68 }
69 result.push(chars.next().unwrap());
70 }
71 result.push('}');
72 } else {
73 result.push(ch);
74 }
75 }
76 result
77}
78
79/// HTTP method for route definitions
80#[derive(Clone, Copy)]
81pub enum HttpMethod {
82 /// HTTP GET method.
83 Get,
84 /// HTTP POST method.
85 Post,
86 /// HTTP PUT method.
87 Put,
88 /// HTTP PATCH method.
89 Patch,
90 /// HTTP DELETE method.
91 Delete,
92}
93
94/// Builder for route definitions that supports `.name()` and `.middleware()` chaining
95pub struct RouteDefBuilder<H> {
96 method: HttpMethod,
97 path: &'static str,
98 handler: H,
99 name: Option<&'static str>,
100 middlewares: Vec<BoxedMiddleware>,
101 mcp_tool_name: Option<String>,
102 mcp_description: Option<String>,
103 mcp_hint: Option<String>,
104 mcp_hidden: bool,
105}
106
107impl<H, Fut> RouteDefBuilder<H>
108where
109 H: Fn(Request) -> Fut + Send + Sync + 'static,
110 Fut: Future<Output = Response> + Send + 'static,
111{
112 /// Create a new route definition builder
113 pub fn new(method: HttpMethod, path: &'static str, handler: H) -> Self {
114 Self {
115 method,
116 path,
117 handler,
118 name: None,
119 middlewares: Vec::new(),
120 mcp_tool_name: None,
121 mcp_description: None,
122 mcp_hint: None,
123 mcp_hidden: false,
124 }
125 }
126
127 /// Name this route for URL generation
128 pub fn name(mut self, name: &'static str) -> Self {
129 self.name = Some(name);
130 self
131 }
132
133 /// Add middleware to this route
134 pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
135 self.middlewares.push(into_boxed(middleware));
136 self
137 }
138
139 /// Override the auto-generated MCP tool name for this route
140 pub fn mcp_tool_name(mut self, name: &str) -> Self {
141 self.mcp_tool_name = Some(name.to_string());
142 self
143 }
144
145 /// Override the auto-generated MCP description for this route
146 pub fn mcp_description(mut self, desc: &str) -> Self {
147 self.mcp_description = Some(desc.to_string());
148 self
149 }
150
151 /// Add a hint that is appended to the MCP description for AI guidance
152 pub fn mcp_hint(mut self, hint: &str) -> Self {
153 self.mcp_hint = Some(hint.to_string());
154 self
155 }
156
157 /// Hide this route from MCP tool discovery
158 pub fn mcp_hidden(mut self) -> Self {
159 self.mcp_hidden = true;
160 self
161 }
162
163 /// Register this route definition with a router
164 pub fn register(self, router: Router) -> Router {
165 // Convert :param to {param} for matchit compatibility
166 let converted_path = convert_route_params(self.path);
167
168 // Capture MCP fields before moving self
169 let has_mcp = self.mcp_tool_name.is_some()
170 || self.mcp_description.is_some()
171 || self.mcp_hint.is_some()
172 || self.mcp_hidden;
173 let mcp_tool_name = self.mcp_tool_name;
174 let mcp_description = self.mcp_description;
175 let mcp_hint = self.mcp_hint;
176 let mcp_hidden = self.mcp_hidden;
177
178 // First, register the route based on method
179 let builder = match self.method {
180 HttpMethod::Get => router.get(&converted_path, self.handler),
181 HttpMethod::Post => router.post(&converted_path, self.handler),
182 HttpMethod::Put => router.put(&converted_path, self.handler),
183 HttpMethod::Patch => router.patch(&converted_path, self.handler),
184 HttpMethod::Delete => router.delete(&converted_path, self.handler),
185 };
186
187 // Apply any middleware
188 let builder = self
189 .middlewares
190 .into_iter()
191 .fold(builder, |b, m| b.middleware_boxed(m));
192
193 // Apply MCP metadata if any fields are set
194 if has_mcp {
195 update_route_mcp(
196 &converted_path,
197 mcp_tool_name,
198 mcp_description,
199 mcp_hint,
200 mcp_hidden,
201 );
202 }
203
204 // Apply name if present, otherwise convert to Router
205 if let Some(name) = self.name {
206 builder.name(name)
207 } else {
208 builder.into()
209 }
210 }
211}
212
213/// Create a GET route definition with compile-time path validation
214///
215/// # Example
216/// ```rust,ignore
217/// get!("/users", controllers::user::index).name("users.index")
218/// ```
219///
220/// # Compile Error
221///
222/// Fails to compile if path doesn't start with '/'.
223#[macro_export]
224macro_rules! get {
225 ($path:expr, $handler:expr) => {{
226 const _: &str = $crate::validate_route_path($path);
227 $crate::__get_impl($path, $handler)
228 }};
229}
230
231/// Internal implementation for GET routes (used by the get! macro)
232#[doc(hidden)]
233pub fn __get_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
234where
235 H: Fn(Request) -> Fut + Send + Sync + 'static,
236 Fut: Future<Output = Response> + Send + 'static,
237{
238 RouteDefBuilder::new(HttpMethod::Get, path, handler)
239}
240
241/// Create a POST route definition with compile-time path validation
242///
243/// # Example
244/// ```rust,ignore
245/// post!("/users", controllers::user::store).name("users.store")
246/// ```
247///
248/// # Compile Error
249///
250/// Fails to compile if path doesn't start with '/'.
251#[macro_export]
252macro_rules! post {
253 ($path:expr, $handler:expr) => {{
254 const _: &str = $crate::validate_route_path($path);
255 $crate::__post_impl($path, $handler)
256 }};
257}
258
259/// Internal implementation for POST routes (used by the post! macro)
260#[doc(hidden)]
261pub fn __post_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
262where
263 H: Fn(Request) -> Fut + Send + Sync + 'static,
264 Fut: Future<Output = Response> + Send + 'static,
265{
266 RouteDefBuilder::new(HttpMethod::Post, path, handler)
267}
268
269/// Create a PUT route definition with compile-time path validation
270///
271/// # Example
272/// ```rust,ignore
273/// put!("/users/{id}", controllers::user::update).name("users.update")
274/// ```
275///
276/// # Compile Error
277///
278/// Fails to compile if path doesn't start with '/'.
279#[macro_export]
280macro_rules! put {
281 ($path:expr, $handler:expr) => {{
282 const _: &str = $crate::validate_route_path($path);
283 $crate::__put_impl($path, $handler)
284 }};
285}
286
287/// Internal implementation for PUT routes (used by the put! macro)
288#[doc(hidden)]
289pub fn __put_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
290where
291 H: Fn(Request) -> Fut + Send + Sync + 'static,
292 Fut: Future<Output = Response> + Send + 'static,
293{
294 RouteDefBuilder::new(HttpMethod::Put, path, handler)
295}
296
297/// Create a PATCH route definition with compile-time path validation
298///
299/// # Example
300/// ```rust,ignore
301/// patch!("/users/{id}", controllers::user::patch).name("users.patch")
302/// ```
303///
304/// # Compile Error
305///
306/// Fails to compile if path doesn't start with '/'.
307#[macro_export]
308macro_rules! patch {
309 ($path:expr, $handler:expr) => {{
310 const _: &str = $crate::validate_route_path($path);
311 $crate::__patch_impl($path, $handler)
312 }};
313}
314
315/// Internal implementation for PATCH routes (used by the patch! macro)
316#[doc(hidden)]
317pub fn __patch_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
318where
319 H: Fn(Request) -> Fut + Send + Sync + 'static,
320 Fut: Future<Output = Response> + Send + 'static,
321{
322 RouteDefBuilder::new(HttpMethod::Patch, path, handler)
323}
324
325/// Create a DELETE route definition with compile-time path validation
326///
327/// # Example
328/// ```rust,ignore
329/// delete!("/users/{id}", controllers::user::destroy).name("users.destroy")
330/// ```
331///
332/// # Compile Error
333///
334/// Fails to compile if path doesn't start with '/'.
335#[macro_export]
336macro_rules! delete {
337 ($path:expr, $handler:expr) => {{
338 const _: &str = $crate::validate_route_path($path);
339 $crate::__delete_impl($path, $handler)
340 }};
341}
342
343/// Internal implementation for DELETE routes (used by the delete! macro)
344#[doc(hidden)]
345pub fn __delete_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
346where
347 H: Fn(Request) -> Fut + Send + Sync + 'static,
348 Fut: Future<Output = Response> + Send + 'static,
349{
350 RouteDefBuilder::new(HttpMethod::Delete, path, handler)
351}
352
353// ============================================================================
354// Fallback Route Support
355// ============================================================================
356
357/// Builder for fallback route definitions that supports `.middleware()` chaining
358///
359/// The fallback route is invoked when no other routes match, allowing custom
360/// handling of 404 scenarios.
361pub struct FallbackDefBuilder<H> {
362 handler: H,
363 middlewares: Vec<BoxedMiddleware>,
364}
365
366impl<H, Fut> FallbackDefBuilder<H>
367where
368 H: Fn(Request) -> Fut + Send + Sync + 'static,
369 Fut: Future<Output = Response> + Send + 'static,
370{
371 /// Create a new fallback definition builder
372 pub fn new(handler: H) -> Self {
373 Self {
374 handler,
375 middlewares: Vec::new(),
376 }
377 }
378
379 /// Add middleware to this fallback route
380 pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
381 self.middlewares.push(into_boxed(middleware));
382 self
383 }
384
385 /// Register this fallback definition with a router
386 pub fn register(self, mut router: Router) -> Router {
387 let handler = self.handler;
388 let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
389 router.set_fallback(Arc::new(boxed));
390
391 // Apply middleware
392 for mw in self.middlewares {
393 router.add_fallback_middleware(mw);
394 }
395
396 router
397 }
398}
399
400/// Create a fallback route definition
401///
402/// The fallback handler is called when no other routes match the request,
403/// allowing you to override the default 404 behavior.
404///
405/// # Example
406/// ```rust,ignore
407/// routes! {
408/// get!("/", controllers::home::index),
409/// get!("/users", controllers::user::index),
410///
411/// // Custom 404 handler
412/// fallback!(controllers::fallback::invoke),
413/// }
414/// ```
415///
416/// With middleware:
417/// ```rust,ignore
418/// routes! {
419/// get!("/", controllers::home::index),
420/// fallback!(controllers::fallback::invoke).middleware(LoggingMiddleware),
421/// }
422/// ```
423#[macro_export]
424macro_rules! fallback {
425 ($handler:expr) => {{
426 $crate::__fallback_impl($handler)
427 }};
428}
429
430/// Internal implementation for fallback routes (used by the fallback! macro)
431#[doc(hidden)]
432pub fn __fallback_impl<H, Fut>(handler: H) -> FallbackDefBuilder<H>
433where
434 H: Fn(Request) -> Fut + Send + Sync + 'static,
435 Fut: Future<Output = Response> + Send + 'static,
436{
437 FallbackDefBuilder::new(handler)
438}
439
440// ============================================================================
441// Route Grouping Support
442// ============================================================================
443
444/// A route stored within a group (type-erased handler)
445pub struct GroupRoute {
446 method: HttpMethod,
447 path: &'static str,
448 handler: Arc<BoxedHandler>,
449 name: Option<&'static str>,
450 middlewares: Vec<BoxedMiddleware>,
451 mcp_tool_name: Option<String>,
452 mcp_description: Option<String>,
453 mcp_hint: Option<String>,
454 mcp_hidden: bool,
455}
456
457/// An item that can be added to a route group - either a route or a nested group
458pub enum GroupItem {
459 /// A single route
460 Route(GroupRoute),
461 /// A nested group with its own prefix and middleware
462 NestedGroup(Box<GroupDef>),
463}
464
465/// Trait for types that can be converted into a GroupItem
466pub trait IntoGroupItem {
467 /// Convert this value into a group item for the route builder.
468 fn into_group_item(self) -> GroupItem;
469}
470
471/// MCP defaults inherited from parent groups to child routes
472#[derive(Default, Clone)]
473struct McpDefaults {
474 tool_name: Option<String>,
475 description: Option<String>,
476 hint: Option<String>,
477 hidden: bool,
478}
479
480/// Group definition that collects routes and applies prefix/middleware
481///
482/// Supports nested groups for arbitrary route organization:
483///
484/// ```rust,ignore
485/// routes! {
486/// group!("/api", {
487/// get!("/users", controllers::user::index).name("api.users"),
488/// post!("/users", controllers::user::store),
489/// // Nested groups are supported
490/// group!("/admin", {
491/// get!("/dashboard", controllers::admin::dashboard),
492/// }),
493/// }).middleware(AuthMiddleware),
494/// }
495/// ```
496pub struct GroupDef {
497 prefix: &'static str,
498 items: Vec<GroupItem>,
499 group_middlewares: Vec<BoxedMiddleware>,
500 mcp_tool_name: Option<String>,
501 mcp_description: Option<String>,
502 mcp_hint: Option<String>,
503 mcp_hidden: bool,
504}
505
506impl GroupDef {
507 /// Create a new route group with the given prefix (internal use)
508 ///
509 /// Use the `group!` macro instead for compile-time validation.
510 #[doc(hidden)]
511 pub fn __new_unchecked(prefix: &'static str) -> Self {
512 Self {
513 prefix,
514 items: Vec::new(),
515 group_middlewares: Vec::new(),
516 mcp_tool_name: None,
517 mcp_description: None,
518 mcp_hint: None,
519 mcp_hidden: false,
520 }
521 }
522
523 /// Add an item (route or nested group) to this group
524 ///
525 /// This is the primary method for adding items to a group. It accepts
526 /// anything that implements `IntoGroupItem`, including routes created
527 /// with `get!`, `post!`, etc., and nested groups created with `group!`.
528 #[allow(clippy::should_implement_trait)]
529 pub fn add<I: IntoGroupItem>(mut self, item: I) -> Self {
530 self.items.push(item.into_group_item());
531 self
532 }
533
534 /// Add a route to this group (backward compatibility)
535 ///
536 /// Prefer using `.add()` which accepts both routes and nested groups.
537 pub fn route<H, Fut>(self, route: RouteDefBuilder<H>) -> Self
538 where
539 H: Fn(Request) -> Fut + Send + Sync + 'static,
540 Fut: Future<Output = Response> + Send + 'static,
541 {
542 self.add(route)
543 }
544
545 /// Add middleware to all routes in this group
546 ///
547 /// Middleware is applied in the order it's added.
548 ///
549 /// # Example
550 ///
551 /// ```rust,ignore
552 /// group!("/api", {
553 /// get!("/users", handler),
554 /// }).middleware(AuthMiddleware).middleware(RateLimitMiddleware)
555 /// ```
556 pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
557 self.group_middlewares.push(into_boxed(middleware));
558 self
559 }
560
561 /// Set a default MCP tool name for all routes in this group
562 ///
563 /// Route-level overrides take precedence.
564 pub fn mcp_tool_name(mut self, name: &str) -> Self {
565 self.mcp_tool_name = Some(name.to_string());
566 self
567 }
568
569 /// Set a default MCP description for all routes in this group
570 ///
571 /// Route-level overrides take precedence.
572 pub fn mcp_description(mut self, desc: &str) -> Self {
573 self.mcp_description = Some(desc.to_string());
574 self
575 }
576
577 /// Set a default MCP hint for all routes in this group
578 ///
579 /// Route-level overrides take precedence.
580 pub fn mcp_hint(mut self, hint: &str) -> Self {
581 self.mcp_hint = Some(hint.to_string());
582 self
583 }
584
585 /// Hide all routes in this group from MCP tool discovery
586 ///
587 /// Route-level overrides take precedence.
588 pub fn mcp_hidden(mut self) -> Self {
589 self.mcp_hidden = true;
590 self
591 }
592
593 /// Register all routes in this group with the router
594 ///
595 /// This prepends the group prefix to each route path and applies
596 /// group middleware to all routes. Nested groups are flattened recursively,
597 /// with prefixes concatenated and middleware inherited from parent groups.
598 ///
599 /// # Path Combination
600 ///
601 /// - If the route path is `"/"` and the accumulated group prefix is
602 /// non-empty, the handler is registered under both `/prefix` and
603 /// `/prefix/` so it is reachable at either URL. Route introspection
604 /// and named-route lookup still report one canonical entry at
605 /// `/prefix` (no trailing slash).
606 /// - A single trailing `/` on the group prefix is stripped before
607 /// concatenation, so `group!("/api/", { get!("/x", ...) })` produces
608 /// `/api/x` rather than `/api//x`.
609 /// - A root prefix `"/"` combined with a root route `"/"` stays `"/"`
610 /// (no `"//"` is ever registered).
611 ///
612 /// # Middleware Inheritance
613 ///
614 /// Parent group middleware is applied before child group middleware,
615 /// which is applied before route-specific middleware.
616 pub fn register(self, mut router: Router) -> Router {
617 let mcp_defaults = McpDefaults::default();
618 self.register_with_inherited(&mut router, "", &[], &mcp_defaults);
619 router
620 }
621
622 /// Internal recursive registration with inherited prefix and middleware
623 fn register_with_inherited(
624 self,
625 router: &mut Router,
626 parent_prefix: &str,
627 inherited_middleware: &[BoxedMiddleware],
628 inherited_mcp: &McpDefaults,
629 ) {
630 // Build the full prefix for this group. Strip a trailing `/` from the
631 // parent prefix before concatenation so nested groups like
632 // `group!("/a/", { group!("/b", ...) })` accumulate to `/a/b` instead
633 // of `/a//b` (D-06, RESEARCH §Pitfall 3).
634 let full_prefix = if parent_prefix.is_empty() {
635 self.prefix.to_string()
636 } else {
637 let stripped_parent = parent_prefix.strip_suffix('/').unwrap_or(parent_prefix);
638 format!("{}{}", stripped_parent, self.prefix)
639 };
640
641 // Combine inherited middleware with this group's middleware
642 // Parent middleware runs first (outer), then this group's middleware
643 let combined_middleware: Vec<BoxedMiddleware> = inherited_middleware
644 .iter()
645 .cloned()
646 .chain(self.group_middlewares.iter().cloned())
647 .collect();
648
649 // Merge MCP defaults: this group's settings override inherited
650 let combined_mcp = McpDefaults {
651 tool_name: self.mcp_tool_name.or(inherited_mcp.tool_name.clone()),
652 description: self.mcp_description.or(inherited_mcp.description.clone()),
653 hint: self.mcp_hint.or(inherited_mcp.hint.clone()),
654 hidden: self.mcp_hidden || inherited_mcp.hidden,
655 };
656
657 for item in self.items {
658 match item {
659 GroupItem::Route(route) => {
660 // Convert :param to {param} for matchit compatibility
661 let converted_route_path = convert_route_params(route.path);
662
663 // Combine the full prefix with the route path using the
664 // shared helper. May emit a second matchit leaf so
665 // `get!("/", ...)` inside a non-root group reaches its
666 // handler at both `/prefix` and `/prefix/` (D-01 through
667 // D-04, D-06).
668 let (canonical, alternate) =
669 combine_group_path(&full_prefix, &converted_route_path);
670 let canonical_path: &'static str = Box::leak(canonical.into_boxed_str());
671 let alternate_path: Option<&'static str> =
672 alternate.map(|s| Box::leak(s.into_boxed_str()) as &'static str);
673
674 // Register the canonical route first (touches
675 // `register_route`, which appends to `REGISTERED_ROUTES`
676 // so `update_route_name` / `update_route_mcp` /
677 // `update_route_middleware` later in this block resolve
678 // via `.rev().find(path == canonical)`). Then register
679 // the alternate as a matchit-only alias carrying the
680 // canonical pattern string so middleware lookup resolves
681 // under the canonical key (Strategy A).
682 match route.method {
683 HttpMethod::Get => {
684 router.insert_get(canonical_path, route.handler.clone());
685 if let Some(alt) = alternate_path {
686 router.insert_get_alias(alt, route.handler, canonical_path);
687 }
688 }
689 HttpMethod::Post => {
690 router.insert_post(canonical_path, route.handler.clone());
691 if let Some(alt) = alternate_path {
692 router.insert_post_alias(alt, route.handler, canonical_path);
693 }
694 }
695 HttpMethod::Put => {
696 router.insert_put(canonical_path, route.handler.clone());
697 if let Some(alt) = alternate_path {
698 router.insert_put_alias(alt, route.handler, canonical_path);
699 }
700 }
701 HttpMethod::Patch => {
702 router.insert_patch(canonical_path, route.handler.clone());
703 if let Some(alt) = alternate_path {
704 router.insert_patch_alias(alt, route.handler, canonical_path);
705 }
706 }
707 HttpMethod::Delete => {
708 router.insert_delete(canonical_path, route.handler.clone());
709 if let Some(alt) = alternate_path {
710 router.insert_delete_alias(alt, route.handler, canonical_path);
711 }
712 }
713 }
714
715 // Register route name if present
716 if let Some(name) = route.name {
717 register_route_name(name, canonical_path);
718 }
719
720 // Apply MCP metadata: route-level overrides group defaults
721 let mcp_tool_name = route.mcp_tool_name.or(combined_mcp.tool_name.clone());
722 let mcp_description =
723 route.mcp_description.or(combined_mcp.description.clone());
724 let mcp_hint = route.mcp_hint.or(combined_mcp.hint.clone());
725 let mcp_hidden = route.mcp_hidden || combined_mcp.hidden;
726
727 if mcp_tool_name.is_some()
728 || mcp_description.is_some()
729 || mcp_hint.is_some()
730 || mcp_hidden
731 {
732 update_route_mcp(
733 canonical_path,
734 mcp_tool_name,
735 mcp_description,
736 mcp_hint,
737 mcp_hidden,
738 );
739 }
740
741 // Apply combined middleware (inherited + group), then route-specific.
742 // The alias leaf stores the canonical pattern so middleware registered
743 // here under canonical_path is reached for both URL variants (Strategy A).
744 for mw in &combined_middleware {
745 router.add_middleware(canonical_path, mw.clone());
746 }
747 for mw in route.middlewares {
748 router.add_middleware(canonical_path, mw);
749 }
750 }
751 GroupItem::NestedGroup(nested) => {
752 // Recursively register the nested group with accumulated prefix and middleware
753 nested.register_with_inherited(
754 router,
755 &full_prefix,
756 &combined_middleware,
757 &combined_mcp,
758 );
759 }
760 }
761 }
762 }
763}
764
765impl<H, Fut> RouteDefBuilder<H>
766where
767 H: Fn(Request) -> Fut + Send + Sync + 'static,
768 Fut: Future<Output = Response> + Send + 'static,
769{
770 /// Convert this route definition to a type-erased GroupRoute
771 ///
772 /// This is used internally when adding routes to a group.
773 pub fn into_group_route(self) -> GroupRoute {
774 let handler = self.handler;
775 let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
776 GroupRoute {
777 method: self.method,
778 path: self.path,
779 handler: Arc::new(boxed),
780 name: self.name,
781 middlewares: self.middlewares,
782 mcp_tool_name: self.mcp_tool_name,
783 mcp_description: self.mcp_description,
784 mcp_hint: self.mcp_hint,
785 mcp_hidden: self.mcp_hidden,
786 }
787 }
788}
789
790// ============================================================================
791// IntoGroupItem implementations
792// ============================================================================
793
794impl<H, Fut> IntoGroupItem for RouteDefBuilder<H>
795where
796 H: Fn(Request) -> Fut + Send + Sync + 'static,
797 Fut: Future<Output = Response> + Send + 'static,
798{
799 fn into_group_item(self) -> GroupItem {
800 GroupItem::Route(self.into_group_route())
801 }
802}
803
804impl IntoGroupItem for GroupDef {
805 fn into_group_item(self) -> GroupItem {
806 GroupItem::NestedGroup(Box::new(self))
807 }
808}
809
810/// Define a route group with a shared prefix
811///
812/// Routes within a group will have the prefix prepended to their paths.
813/// Middleware can be applied to the entire group using `.middleware()`.
814/// Groups can be nested arbitrarily deep.
815///
816/// # Example
817///
818/// ```rust,ignore
819/// use ferro_rs::{routes, get, post, group};
820///
821/// routes! {
822/// get!("/", controllers::home::index),
823///
824/// // All routes in this group start with /api
825/// group!("/api", {
826/// get!("/users", controllers::user::index), // -> GET /api/users
827/// post!("/users", controllers::user::store), // -> POST /api/users
828///
829/// // Nested groups are supported
830/// group!("/admin", {
831/// get!("/dashboard", controllers::admin::dashboard), // -> GET /api/admin/dashboard
832/// }),
833/// }).middleware(AuthMiddleware), // Applies to ALL routes including nested
834/// }
835/// ```
836///
837/// # Middleware Inheritance
838///
839/// Middleware applied to a parent group is automatically inherited by all nested groups.
840/// The execution order is: parent middleware -> child middleware -> route middleware.
841///
842/// # Compile Error
843///
844/// Fails to compile if prefix doesn't start with '/'.
845#[macro_export]
846macro_rules! group {
847 ($prefix:expr, { $( $item:expr ),* $(,)? }) => {{
848 const _: &str = $crate::validate_route_path($prefix);
849 let mut group = $crate::GroupDef::__new_unchecked($prefix);
850 $(
851 group = group.add($item);
852 )*
853 group
854 }};
855}
856
857/// Define routes with a clean, Laravel-like syntax
858///
859/// This macro generates a `pub fn register() -> Router` function automatically.
860/// Place it at the top level of your `routes.rs` file.
861///
862/// # Example
863/// ```rust,ignore
864/// use ferro_rs::{routes, get, post, put, delete};
865/// use ferro_rs::controllers;
866/// use ferro_rs::middleware::AuthMiddleware;
867///
868/// routes! {
869/// get!("/", controllers::home::index).name("home"),
870/// get!("/users", controllers::user::index).name("users.index"),
871/// get!("/users/{id}", controllers::user::show).name("users.show"),
872/// post!("/users", controllers::user::store).name("users.store"),
873/// put!("/users/{id}", controllers::user::update).name("users.update"),
874/// delete!("/users/{id}", controllers::user::destroy).name("users.destroy"),
875/// get!("/protected", controllers::home::index).middleware(AuthMiddleware),
876/// }
877/// ```
878#[macro_export]
879macro_rules! routes {
880 ( $( $route:expr ),* $(,)? ) => {
881 pub fn register() -> $crate::Router {
882 let mut router = $crate::Router::new();
883 $(
884 router = $route.register(router);
885 )*
886 router
887 }
888 };
889}
890
891// ============================================================================
892// RESTful Resource Routing Support
893// ============================================================================
894
895/// Actions available for resource routing
896#[derive(Clone, Copy, PartialEq, Eq, Debug)]
897pub enum ResourceAction {
898 /// List all resources (GET /resources).
899 Index,
900 /// Show creation form (GET /resources/create).
901 Create,
902 /// Persist a new resource (POST /resources).
903 Store,
904 /// Display a single resource (GET /resources/{id}).
905 Show,
906 /// Show edit form (GET /resources/{id}/edit).
907 Edit,
908 /// Update an existing resource (PUT /resources/{id}).
909 Update,
910 /// Delete a resource (DELETE /resources/{id}).
911 Destroy,
912}
913
914impl ResourceAction {
915 /// Get all available resource actions
916 pub const fn all() -> &'static [ResourceAction] {
917 &[
918 ResourceAction::Index,
919 ResourceAction::Create,
920 ResourceAction::Store,
921 ResourceAction::Show,
922 ResourceAction::Edit,
923 ResourceAction::Update,
924 ResourceAction::Destroy,
925 ]
926 }
927
928 /// Get the HTTP method for this action
929 pub const fn method(&self) -> HttpMethod {
930 match self {
931 ResourceAction::Index => HttpMethod::Get,
932 ResourceAction::Create => HttpMethod::Get,
933 ResourceAction::Store => HttpMethod::Post,
934 ResourceAction::Show => HttpMethod::Get,
935 ResourceAction::Edit => HttpMethod::Get,
936 ResourceAction::Update => HttpMethod::Put,
937 ResourceAction::Destroy => HttpMethod::Delete,
938 }
939 }
940
941 /// Get the path suffix for this action (relative to resource path)
942 pub const fn path_suffix(&self) -> &'static str {
943 match self {
944 ResourceAction::Index => "/",
945 ResourceAction::Create => "/create",
946 ResourceAction::Store => "/",
947 ResourceAction::Show => "/{id}",
948 ResourceAction::Edit => "/{id}/edit",
949 ResourceAction::Update => "/{id}",
950 ResourceAction::Destroy => "/{id}",
951 }
952 }
953
954 /// Get the route name suffix for this action
955 pub const fn name_suffix(&self) -> &'static str {
956 match self {
957 ResourceAction::Index => "index",
958 ResourceAction::Create => "create",
959 ResourceAction::Store => "store",
960 ResourceAction::Show => "show",
961 ResourceAction::Edit => "edit",
962 ResourceAction::Update => "update",
963 ResourceAction::Destroy => "destroy",
964 }
965 }
966}
967
968/// A resource route stored within a ResourceDef (type-erased handler)
969pub struct ResourceRoute {
970 action: ResourceAction,
971 handler: Arc<BoxedHandler>,
972}
973
974/// Resource definition that generates RESTful routes from a controller module
975///
976/// Generates 7 standard routes following Rails/Laravel conventions:
977///
978/// - GET /users -> index (list all)
979/// - GET /users/create -> create (show create form)
980/// - POST /users -> store (create new)
981/// - GET /users/{id} -> show (show one)
982/// - GET /users/{id}/edit -> edit (show edit form)
983/// - PUT /users/{id} -> update (update one)
984/// - DELETE /users/{id} -> destroy (delete one)
985///
986/// Route names are auto-generated: users.index, users.create, etc.
987///
988/// # Example
989///
990/// ```rust,ignore
991/// routes! {
992/// resource!("/users", controllers::user),
993/// resource!("/posts", controllers::post).middleware(AuthMiddleware),
994/// resource!("/comments", controllers::comment, only: [index, show]),
995/// }
996/// ```
997pub struct ResourceDef {
998 prefix: &'static str,
999 routes: Vec<ResourceRoute>,
1000 middlewares: Vec<BoxedMiddleware>,
1001}
1002
1003impl ResourceDef {
1004 /// Create a new resource definition with no routes (internal use)
1005 #[doc(hidden)]
1006 pub fn __new_unchecked(prefix: &'static str) -> Self {
1007 Self {
1008 prefix,
1009 routes: Vec::new(),
1010 middlewares: Vec::new(),
1011 }
1012 }
1013
1014 /// Add a route for a specific action
1015 #[doc(hidden)]
1016 pub fn __add_route(mut self, action: ResourceAction, handler: Arc<BoxedHandler>) -> Self {
1017 self.routes.push(ResourceRoute { action, handler });
1018 self
1019 }
1020
1021 /// Add middleware to all routes in this resource
1022 ///
1023 /// # Example
1024 ///
1025 /// ```rust,ignore
1026 /// resource!("/admin", controllers::admin, only: [index, show])
1027 /// .middleware(AuthMiddleware)
1028 /// .middleware(AdminMiddleware)
1029 /// ```
1030 pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
1031 self.middlewares.push(into_boxed(middleware));
1032 self
1033 }
1034
1035 /// Register all resource routes with the router
1036 pub fn register(self, mut router: Router) -> Router {
1037 // Derive route name prefix from path: "/users" -> "users", "/api/users" -> "api.users"
1038 let name_prefix = self.prefix.trim_start_matches('/').replace('/', ".");
1039
1040 for route in self.routes {
1041 let action = route.action;
1042 let path_suffix = action.path_suffix();
1043
1044 // Build full path
1045 let full_path = if path_suffix == "/" {
1046 self.prefix.to_string()
1047 } else {
1048 format!("{}{}", self.prefix, path_suffix)
1049 };
1050 let full_path: &'static str = Box::leak(full_path.into_boxed_str());
1051
1052 // Build route name
1053 let route_name = format!("{}.{}", name_prefix, action.name_suffix());
1054 let route_name: &'static str = Box::leak(route_name.into_boxed_str());
1055
1056 // Register the route
1057 match action.method() {
1058 HttpMethod::Get => {
1059 router.insert_get(full_path, route.handler);
1060 }
1061 HttpMethod::Post => {
1062 router.insert_post(full_path, route.handler);
1063 }
1064 HttpMethod::Put => {
1065 router.insert_put(full_path, route.handler);
1066 }
1067 HttpMethod::Patch => {
1068 router.insert_patch(full_path, route.handler);
1069 }
1070 HttpMethod::Delete => {
1071 router.insert_delete(full_path, route.handler);
1072 }
1073 }
1074
1075 // Register route name
1076 register_route_name(route_name, full_path);
1077
1078 // Apply middleware
1079 for mw in &self.middlewares {
1080 router.add_middleware(full_path, mw.clone());
1081 }
1082 }
1083
1084 router
1085 }
1086}
1087
1088/// Helper function to create a boxed handler from a handler function
1089#[doc(hidden)]
1090pub fn __box_handler<H, Fut>(handler: H) -> Arc<BoxedHandler>
1091where
1092 H: Fn(Request) -> Fut + Send + Sync + 'static,
1093 Fut: Future<Output = Response> + Send + 'static,
1094{
1095 let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
1096 Arc::new(boxed)
1097}
1098
1099/// Define RESTful resource routes with convention-over-configuration
1100///
1101/// Generates 7 standard routes following Rails/Laravel conventions from a
1102/// controller module reference.
1103///
1104/// # Convention Mapping
1105///
1106/// | Method | Path | Action | Route Name |
1107/// |--------|-----------------|---------|---------------|
1108/// | GET | /users | index | users.index |
1109/// | GET | /users/create | create | users.create |
1110/// | POST | /users | store | users.store |
1111/// | GET | /users/{id} | show | users.show |
1112/// | GET | /users/{id}/edit| edit | users.edit |
1113/// | PUT | /users/{id} | update | users.update |
1114/// | DELETE | /users/{id} | destroy | users.destroy |
1115///
1116/// # Basic Usage
1117///
1118/// ```rust,ignore
1119/// routes! {
1120/// resource!("/users", controllers::user),
1121/// }
1122/// ```
1123///
1124/// # With Middleware
1125///
1126/// ```rust,ignore
1127/// routes! {
1128/// resource!("/admin", controllers::admin).middleware(AuthMiddleware),
1129/// }
1130/// ```
1131///
1132/// # Subset of Actions
1133///
1134/// Use `only:` to generate only specific routes:
1135///
1136/// ```rust,ignore
1137/// routes! {
1138/// // Only index, show, and store - no create/edit forms, update, or destroy
1139/// resource!("/posts", controllers::post, only: [index, show, store]),
1140/// }
1141/// ```
1142///
1143/// # Path Naming
1144///
1145/// Route names are derived from the path:
1146/// - `/users` → `users.index`, `users.show`, etc.
1147/// - `/api/users` → `api.users.index`, `api.users.show`, etc.
1148///
1149/// # Compile Error
1150///
1151/// Fails to compile if path doesn't start with '/'.
1152#[macro_export]
1153macro_rules! resource {
1154 // Full resource (all 7 routes)
1155 // Note: The module path is followed by path segments to each handler
1156 ($path:expr, $($controller:ident)::+) => {{
1157 const _: &str = $crate::validate_route_path($path);
1158 $crate::ResourceDef::__new_unchecked($path)
1159 .__add_route($crate::ResourceAction::Index, $crate::__box_handler($($controller)::+::index))
1160 .__add_route($crate::ResourceAction::Create, $crate::__box_handler($($controller)::+::create))
1161 .__add_route($crate::ResourceAction::Store, $crate::__box_handler($($controller)::+::store))
1162 .__add_route($crate::ResourceAction::Show, $crate::__box_handler($($controller)::+::show))
1163 .__add_route($crate::ResourceAction::Edit, $crate::__box_handler($($controller)::+::edit))
1164 .__add_route($crate::ResourceAction::Update, $crate::__box_handler($($controller)::+::update))
1165 .__add_route($crate::ResourceAction::Destroy, $crate::__box_handler($($controller)::+::destroy))
1166 }};
1167
1168 // Subset of routes with `only:` parameter
1169 ($path:expr, $($controller:ident)::+, only: [$($action:ident),* $(,)?]) => {{
1170 const _: &str = $crate::validate_route_path($path);
1171 let mut resource = $crate::ResourceDef::__new_unchecked($path);
1172 $(
1173 resource = resource.__add_route(
1174 $crate::__resource_action!($action),
1175 $crate::__box_handler($($controller)::+::$action)
1176 );
1177 )*
1178 resource
1179 }};
1180}
1181
1182/// Internal macro to convert action identifier to ResourceAction enum
1183#[doc(hidden)]
1184#[macro_export]
1185macro_rules! __resource_action {
1186 (index) => {
1187 $crate::ResourceAction::Index
1188 };
1189 (create) => {
1190 $crate::ResourceAction::Create
1191 };
1192 (store) => {
1193 $crate::ResourceAction::Store
1194 };
1195 (show) => {
1196 $crate::ResourceAction::Show
1197 };
1198 (edit) => {
1199 $crate::ResourceAction::Edit
1200 };
1201 (update) => {
1202 $crate::ResourceAction::Update
1203 };
1204 (destroy) => {
1205 $crate::ResourceAction::Destroy
1206 };
1207}
1208
1209#[cfg(test)]
1210mod tests {
1211 use super::*;
1212
1213 #[test]
1214 fn test_convert_route_params() {
1215 // Basic parameter conversion
1216 assert_eq!(convert_route_params("/users/:id"), "/users/{id}");
1217
1218 // Multiple parameters
1219 assert_eq!(
1220 convert_route_params("/posts/:post_id/comments/:id"),
1221 "/posts/{post_id}/comments/{id}"
1222 );
1223
1224 // Already uses matchit syntax - should be unchanged
1225 assert_eq!(convert_route_params("/users/{id}"), "/users/{id}");
1226
1227 // No parameters - should be unchanged
1228 assert_eq!(convert_route_params("/users"), "/users");
1229 assert_eq!(convert_route_params("/"), "/");
1230
1231 // Mixed syntax (edge case)
1232 assert_eq!(
1233 convert_route_params("/users/:user_id/posts/{post_id}"),
1234 "/users/{user_id}/posts/{post_id}"
1235 );
1236
1237 // Parameter at the end
1238 assert_eq!(
1239 convert_route_params("/api/v1/:version"),
1240 "/api/v1/{version}"
1241 );
1242 }
1243
1244 // Helper for creating test handlers
1245 async fn test_handler(_req: Request) -> Response {
1246 crate::http::text("ok")
1247 }
1248
1249 #[test]
1250 fn test_group_item_route() {
1251 // Test that RouteDefBuilder can be converted to GroupItem
1252 let route_builder = RouteDefBuilder::new(HttpMethod::Get, "/test", test_handler);
1253 let item = route_builder.into_group_item();
1254 matches!(item, GroupItem::Route(_));
1255 }
1256
1257 #[test]
1258 fn test_group_item_nested_group() {
1259 // Test that GroupDef can be converted to GroupItem
1260 let group_def = GroupDef::__new_unchecked("/nested");
1261 let item = group_def.into_group_item();
1262 matches!(item, GroupItem::NestedGroup(_));
1263 }
1264
1265 #[test]
1266 fn test_group_add_route() {
1267 // Test adding a route to a group
1268 let group = GroupDef::__new_unchecked("/api").add(RouteDefBuilder::new(
1269 HttpMethod::Get,
1270 "/users",
1271 test_handler,
1272 ));
1273
1274 assert_eq!(group.items.len(), 1);
1275 matches!(&group.items[0], GroupItem::Route(_));
1276 }
1277
1278 #[test]
1279 fn test_group_add_nested_group() {
1280 // Test adding a nested group to a group
1281 let nested = GroupDef::__new_unchecked("/users");
1282 let group = GroupDef::__new_unchecked("/api").add(nested);
1283
1284 assert_eq!(group.items.len(), 1);
1285 matches!(&group.items[0], GroupItem::NestedGroup(_));
1286 }
1287
1288 #[test]
1289 fn test_group_mixed_items() {
1290 // Test adding both routes and nested groups
1291 let nested = GroupDef::__new_unchecked("/admin");
1292 let group = GroupDef::__new_unchecked("/api")
1293 .add(RouteDefBuilder::new(
1294 HttpMethod::Get,
1295 "/users",
1296 test_handler,
1297 ))
1298 .add(nested)
1299 .add(RouteDefBuilder::new(
1300 HttpMethod::Post,
1301 "/users",
1302 test_handler,
1303 ));
1304
1305 assert_eq!(group.items.len(), 3);
1306 matches!(&group.items[0], GroupItem::Route(_));
1307 matches!(&group.items[1], GroupItem::NestedGroup(_));
1308 matches!(&group.items[2], GroupItem::Route(_));
1309 }
1310
1311 #[test]
1312 fn test_deep_nesting() {
1313 // Test deeply nested groups (3 levels)
1314 let level3 = GroupDef::__new_unchecked("/level3").add(RouteDefBuilder::new(
1315 HttpMethod::Get,
1316 "/",
1317 test_handler,
1318 ));
1319
1320 let level2 = GroupDef::__new_unchecked("/level2").add(level3);
1321
1322 let level1 = GroupDef::__new_unchecked("/level1").add(level2);
1323
1324 assert_eq!(level1.items.len(), 1);
1325 if let GroupItem::NestedGroup(l2) = &level1.items[0] {
1326 assert_eq!(l2.items.len(), 1);
1327 if let GroupItem::NestedGroup(l3) = &l2.items[0] {
1328 assert_eq!(l3.items.len(), 1);
1329 } else {
1330 panic!("Expected nested group at level 2");
1331 }
1332 } else {
1333 panic!("Expected nested group at level 1");
1334 }
1335 }
1336
1337 #[test]
1338 fn test_backward_compatibility_route_method() {
1339 // Test that the old .route() method still works
1340 let group = GroupDef::__new_unchecked("/api").route(RouteDefBuilder::new(
1341 HttpMethod::Get,
1342 "/users",
1343 test_handler,
1344 ));
1345
1346 assert_eq!(group.items.len(), 1);
1347 matches!(&group.items[0], GroupItem::Route(_));
1348 }
1349
1350 // -------------------------------------------------------------------------
1351 // D-01 through D-08 full-router dispatch tests
1352 // -------------------------------------------------------------------------
1353
1354 #[test]
1355 fn group_root_handler_matches_both_variants() {
1356 // D-01: group!("/api", { get!("/", h) }) reaches h at both /api and /api/
1357 use crate::routing::get_registered_routes;
1358 use hyper::Method;
1359
1360 let group = GroupDef::__new_unchecked("/api-d01").add(RouteDefBuilder::new(
1361 HttpMethod::Get,
1362 "/",
1363 test_handler,
1364 ));
1365 let router = group.register(Router::new());
1366
1367 // D-07: exactly one RouteInfo entry for /api-d01 (no duplicate for the alias)
1368 let routes = get_registered_routes();
1369 let count = routes.iter().filter(|r| r.path == "/api-d01").count();
1370 assert_eq!(
1371 count, 1,
1372 "expected exactly 1 RouteInfo entry for /api-d01, got {count}"
1373 );
1374
1375 // Both URL variants reach the handler with the canonical pattern
1376 let hit_canonical = router.match_route(&Method::GET, "/api-d01");
1377 assert!(hit_canonical.is_some(), "canonical /api-d01 did not match");
1378 assert_eq!(hit_canonical.unwrap().2, "/api-d01");
1379
1380 let hit_alternate = router.match_route(&Method::GET, "/api-d01/");
1381 assert!(hit_alternate.is_some(), "alternate /api-d01/ did not match");
1382 assert_eq!(
1383 hit_alternate.unwrap().2,
1384 "/api-d01",
1385 "alternate leaf must carry canonical pattern for middleware lookup"
1386 );
1387 }
1388
1389 #[test]
1390 fn root_prefix_root_handler_is_single_slash() {
1391 // D-02: group!("/", { get!("/", h) }) registers exactly one / route.
1392 // Uses a unique prefix to avoid collision with other tests that also register "/".
1393 use crate::routing::get_registered_routes;
1394 use hyper::Method;
1395
1396 let group = GroupDef::__new_unchecked("/").add(RouteDefBuilder::new(
1397 HttpMethod::Get,
1398 "/",
1399 test_handler,
1400 ));
1401 let router = group.register(Router::new());
1402
1403 // D-07 / D-02: no duplicate RouteInfo for "/" — check via matchit (only one
1404 // canonical leaf exists; a duplicate would cause insert to silently overwrite,
1405 // which matchit handles idempotently, so the behaviour test below is the proof).
1406 let hit = router.match_route(&Method::GET, "/");
1407 assert!(hit.is_some(), "/ did not match");
1408 assert_eq!(hit.unwrap().2, "/");
1409
1410 // No spurious "//" alternate registered
1411 let double = router.match_route(&Method::GET, "//");
1412 assert!(
1413 double.is_none(),
1414 "// must not be registered for root-in-root group"
1415 );
1416
1417 // Single RouteInfo for "/" — filter by exact path in the global registry
1418 let routes = get_registered_routes();
1419 let slash_count = routes
1420 .iter()
1421 .filter(|r| r.path == "/" && r.method == "GET")
1422 .count();
1423 // At least 1 (this test registered one); must be exactly 1 more than before.
1424 // We cannot assert == 1 globally (other tests may have registered "/").
1425 // Assert no duplicate within this session for this specific invocation path by
1426 // verifying the router itself only has one matchit leaf — done via the match above.
1427 assert!(slash_count >= 1, "expected at least one GET / in registry");
1428 }
1429
1430 #[test]
1431 fn trailing_slash_prefix_is_stripped() {
1432 // D-03: group!("/api/", { get!("/x", h) }) normalises to /api/x, not /api//x
1433 use hyper::Method;
1434
1435 let group = GroupDef::__new_unchecked("/api/").add(RouteDefBuilder::new(
1436 HttpMethod::Get,
1437 "/x",
1438 test_handler,
1439 ));
1440 let router = group.register(Router::new());
1441
1442 let hit = router.match_route(&Method::GET, "/api/x");
1443 assert!(
1444 hit.is_some(),
1445 "/api/x did not match after trailing-slash strip"
1446 );
1447
1448 let double = router.match_route(&Method::GET, "/api//x");
1449 assert!(
1450 double.is_none(),
1451 "/api//x should not match (double slash must not be registered)"
1452 );
1453 }
1454
1455 #[test]
1456 fn non_root_prefix_non_root_path_unchanged() {
1457 // D-04 regression: group!("/api-d04", { get!("/users", h) }) produces /api-d04/users, no alternate
1458 use crate::routing::get_registered_routes;
1459 use hyper::Method;
1460
1461 let group = GroupDef::__new_unchecked("/api-d04").add(RouteDefBuilder::new(
1462 HttpMethod::Get,
1463 "/users",
1464 test_handler,
1465 ));
1466 let router = group.register(Router::new());
1467
1468 // Canonical path must match
1469 let hit = router.match_route(&Method::GET, "/api-d04/users");
1470 assert!(hit.is_some(), "/api-d04/users did not match");
1471
1472 // No spurious trailing-slash alternate for non-root route paths (D-04)
1473 let alt = router.match_route(&Method::GET, "/api-d04/users/");
1474 assert!(
1475 alt.is_none(),
1476 "/api-d04/users/ must not be registered (no alternate for non-root path)"
1477 );
1478
1479 // Exactly one RouteInfo entry for this canonical path (D-07)
1480 let routes = get_registered_routes();
1481 let count = routes.iter().filter(|r| r.path == "/api-d04/users").count();
1482 assert_eq!(
1483 count, 1,
1484 "expected exactly 1 RouteInfo for /api-d04/users, got {count}"
1485 );
1486 }
1487
1488 #[test]
1489 fn nested_group_root_matches_both_variants() {
1490 // D-06 + Pitfall 3: nested groups with root handler reach both variants
1491 use hyper::Method;
1492
1493 // Case 1: clean prefixes
1494 let inner1 = GroupDef::__new_unchecked("/b").add(RouteDefBuilder::new(
1495 HttpMethod::Get,
1496 "/",
1497 test_handler,
1498 ));
1499 let router1 = GroupDef::__new_unchecked("/a")
1500 .add(inner1)
1501 .register(Router::new());
1502 assert!(
1503 router1.match_route(&Method::GET, "/a/b").is_some(),
1504 "/a/b did not match"
1505 );
1506 assert!(
1507 router1.match_route(&Method::GET, "/a/b/").is_some(),
1508 "/a/b/ did not match"
1509 );
1510
1511 // Case 2: outer prefix has trailing slash (Pitfall 3)
1512 let inner2 = GroupDef::__new_unchecked("/b").add(RouteDefBuilder::new(
1513 HttpMethod::Get,
1514 "/",
1515 test_handler,
1516 ));
1517 let router2 = GroupDef::__new_unchecked("/a/")
1518 .add(inner2)
1519 .register(Router::new());
1520 assert!(
1521 router2.match_route(&Method::GET, "/a/b").is_some(),
1522 "/a/b did not match with trailing-slash outer"
1523 );
1524 assert!(
1525 router2.match_route(&Method::GET, "/a/b/").is_some(),
1526 "/a/b/ did not match with trailing-slash outer"
1527 );
1528 }
1529
1530 #[test]
1531 fn named_route_resolves_to_canonical() {
1532 // D-08: named routes register only the canonical path
1533 use crate::routing::route;
1534
1535 let group = GroupDef::__new_unchecked("/api").add(
1536 RouteDefBuilder::new(HttpMethod::Get, "/", test_handler).name("home_canonical_test"),
1537 );
1538 let _router = group.register(Router::new());
1539
1540 let url = route("home_canonical_test", &[]);
1541 assert_eq!(
1542 url,
1543 Some("/api".to_string()),
1544 "named route must resolve to canonical /api, not /api/"
1545 );
1546 }
1547
1548 #[test]
1549 fn top_level_root_route_is_single_slash() {
1550 // Regression: top-level get!("/", h) (not inside a group) must still match /.
1551 // Confirms the fix lives only in GroupDef, not in RouteDefBuilder::register.
1552 use hyper::Method;
1553
1554 let router: Router = Router::new().get("/", test_handler).into();
1555
1556 let hit = router.match_route(&Method::GET, "/");
1557 assert!(hit.is_some(), "top-level / did not match");
1558 assert_eq!(hit.unwrap().2, "/", "top-level / pattern must be /");
1559
1560 // No spurious alternate — top-level router.get() does not emit aliases
1561 let double = router.match_route(&Method::GET, "//");
1562 assert!(
1563 double.is_none(),
1564 "// must not match from a top-level get!(\"/\") registration"
1565 );
1566 }
1567}