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::router::update_route_mcp;
40use crate::middleware::{into_boxed, BoxedMiddleware, Middleware};
41use crate::routing::router::{register_route_name, BoxedHandler, Router};
42use std::future::Future;
43use std::sync::Arc;
44
45/// Convert Express-style `:param` route parameters to matchit-style `{param}`
46///
47/// This allows developers to use either syntax:
48/// - `/:id` (Express/Rails style)
49/// - `/{id}` (matchit native style)
50///
51/// # Examples
52///
53/// - `/users/:id` → `/users/{id}`
54/// - `/posts/:post_id/comments/:id` → `/posts/{post_id}/comments/{id}`
55/// - `/users/{id}` → `/users/{id}` (already correct syntax, unchanged)
56fn convert_route_params(path: &str) -> String {
57 let mut result = String::with_capacity(path.len() + 4); // Extra space for braces
58 let mut chars = path.chars().peekable();
59
60 while let Some(ch) = chars.next() {
61 if ch == ':' {
62 // Start of parameter - collect until '/' or end
63 result.push('{');
64 while let Some(&next) = chars.peek() {
65 if next == '/' {
66 break;
67 }
68 result.push(chars.next().unwrap());
69 }
70 result.push('}');
71 } else {
72 result.push(ch);
73 }
74 }
75 result
76}
77
78/// HTTP method for route definitions
79#[derive(Clone, Copy)]
80pub enum HttpMethod {
81 /// HTTP GET method.
82 Get,
83 /// HTTP POST method.
84 Post,
85 /// HTTP PUT method.
86 Put,
87 /// HTTP PATCH method.
88 Patch,
89 /// HTTP DELETE method.
90 Delete,
91}
92
93/// Builder for route definitions that supports `.name()` and `.middleware()` chaining
94pub struct RouteDefBuilder<H> {
95 method: HttpMethod,
96 path: &'static str,
97 handler: H,
98 name: Option<&'static str>,
99 middlewares: Vec<BoxedMiddleware>,
100 mcp_tool_name: Option<String>,
101 mcp_description: Option<String>,
102 mcp_hint: Option<String>,
103 mcp_hidden: bool,
104}
105
106impl<H, Fut> RouteDefBuilder<H>
107where
108 H: Fn(Request) -> Fut + Send + Sync + 'static,
109 Fut: Future<Output = Response> + Send + 'static,
110{
111 /// Create a new route definition builder
112 pub fn new(method: HttpMethod, path: &'static str, handler: H) -> Self {
113 Self {
114 method,
115 path,
116 handler,
117 name: None,
118 middlewares: Vec::new(),
119 mcp_tool_name: None,
120 mcp_description: None,
121 mcp_hint: None,
122 mcp_hidden: false,
123 }
124 }
125
126 /// Name this route for URL generation
127 pub fn name(mut self, name: &'static str) -> Self {
128 self.name = Some(name);
129 self
130 }
131
132 /// Add middleware to this route
133 pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
134 self.middlewares.push(into_boxed(middleware));
135 self
136 }
137
138 /// Override the auto-generated MCP tool name for this route
139 pub fn mcp_tool_name(mut self, name: &str) -> Self {
140 self.mcp_tool_name = Some(name.to_string());
141 self
142 }
143
144 /// Override the auto-generated MCP description for this route
145 pub fn mcp_description(mut self, desc: &str) -> Self {
146 self.mcp_description = Some(desc.to_string());
147 self
148 }
149
150 /// Add a hint that is appended to the MCP description for AI guidance
151 pub fn mcp_hint(mut self, hint: &str) -> Self {
152 self.mcp_hint = Some(hint.to_string());
153 self
154 }
155
156 /// Hide this route from MCP tool discovery
157 pub fn mcp_hidden(mut self) -> Self {
158 self.mcp_hidden = true;
159 self
160 }
161
162 /// Register this route definition with a router
163 pub fn register(self, router: Router) -> Router {
164 // Convert :param to {param} for matchit compatibility
165 let converted_path = convert_route_params(self.path);
166
167 // Capture MCP fields before moving self
168 let has_mcp = self.mcp_tool_name.is_some()
169 || self.mcp_description.is_some()
170 || self.mcp_hint.is_some()
171 || self.mcp_hidden;
172 let mcp_tool_name = self.mcp_tool_name;
173 let mcp_description = self.mcp_description;
174 let mcp_hint = self.mcp_hint;
175 let mcp_hidden = self.mcp_hidden;
176
177 // First, register the route based on method
178 let builder = match self.method {
179 HttpMethod::Get => router.get(&converted_path, self.handler),
180 HttpMethod::Post => router.post(&converted_path, self.handler),
181 HttpMethod::Put => router.put(&converted_path, self.handler),
182 HttpMethod::Patch => router.patch(&converted_path, self.handler),
183 HttpMethod::Delete => router.delete(&converted_path, self.handler),
184 };
185
186 // Apply any middleware
187 let builder = self
188 .middlewares
189 .into_iter()
190 .fold(builder, |b, m| b.middleware_boxed(m));
191
192 // Apply MCP metadata if any fields are set
193 if has_mcp {
194 update_route_mcp(
195 &converted_path,
196 mcp_tool_name,
197 mcp_description,
198 mcp_hint,
199 mcp_hidden,
200 );
201 }
202
203 // Apply name if present, otherwise convert to Router
204 if let Some(name) = self.name {
205 builder.name(name)
206 } else {
207 builder.into()
208 }
209 }
210}
211
212/// Create a GET route definition with compile-time path validation
213///
214/// # Example
215/// ```rust,ignore
216/// get!("/users", controllers::user::index).name("users.index")
217/// ```
218///
219/// # Compile Error
220///
221/// Fails to compile if path doesn't start with '/'.
222#[macro_export]
223macro_rules! get {
224 ($path:expr, $handler:expr) => {{
225 const _: &str = $crate::validate_route_path($path);
226 $crate::__get_impl($path, $handler)
227 }};
228}
229
230/// Internal implementation for GET routes (used by the get! macro)
231#[doc(hidden)]
232pub fn __get_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
233where
234 H: Fn(Request) -> Fut + Send + Sync + 'static,
235 Fut: Future<Output = Response> + Send + 'static,
236{
237 RouteDefBuilder::new(HttpMethod::Get, path, handler)
238}
239
240/// Create a POST route definition with compile-time path validation
241///
242/// # Example
243/// ```rust,ignore
244/// post!("/users", controllers::user::store).name("users.store")
245/// ```
246///
247/// # Compile Error
248///
249/// Fails to compile if path doesn't start with '/'.
250#[macro_export]
251macro_rules! post {
252 ($path:expr, $handler:expr) => {{
253 const _: &str = $crate::validate_route_path($path);
254 $crate::__post_impl($path, $handler)
255 }};
256}
257
258/// Internal implementation for POST routes (used by the post! macro)
259#[doc(hidden)]
260pub fn __post_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
261where
262 H: Fn(Request) -> Fut + Send + Sync + 'static,
263 Fut: Future<Output = Response> + Send + 'static,
264{
265 RouteDefBuilder::new(HttpMethod::Post, path, handler)
266}
267
268/// Create a PUT route definition with compile-time path validation
269///
270/// # Example
271/// ```rust,ignore
272/// put!("/users/{id}", controllers::user::update).name("users.update")
273/// ```
274///
275/// # Compile Error
276///
277/// Fails to compile if path doesn't start with '/'.
278#[macro_export]
279macro_rules! put {
280 ($path:expr, $handler:expr) => {{
281 const _: &str = $crate::validate_route_path($path);
282 $crate::__put_impl($path, $handler)
283 }};
284}
285
286/// Internal implementation for PUT routes (used by the put! macro)
287#[doc(hidden)]
288pub fn __put_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
289where
290 H: Fn(Request) -> Fut + Send + Sync + 'static,
291 Fut: Future<Output = Response> + Send + 'static,
292{
293 RouteDefBuilder::new(HttpMethod::Put, path, handler)
294}
295
296/// Create a PATCH route definition with compile-time path validation
297///
298/// # Example
299/// ```rust,ignore
300/// patch!("/users/{id}", controllers::user::patch).name("users.patch")
301/// ```
302///
303/// # Compile Error
304///
305/// Fails to compile if path doesn't start with '/'.
306#[macro_export]
307macro_rules! patch {
308 ($path:expr, $handler:expr) => {{
309 const _: &str = $crate::validate_route_path($path);
310 $crate::__patch_impl($path, $handler)
311 }};
312}
313
314/// Internal implementation for PATCH routes (used by the patch! macro)
315#[doc(hidden)]
316pub fn __patch_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
317where
318 H: Fn(Request) -> Fut + Send + Sync + 'static,
319 Fut: Future<Output = Response> + Send + 'static,
320{
321 RouteDefBuilder::new(HttpMethod::Patch, path, handler)
322}
323
324/// Create a DELETE route definition with compile-time path validation
325///
326/// # Example
327/// ```rust,ignore
328/// delete!("/users/{id}", controllers::user::destroy).name("users.destroy")
329/// ```
330///
331/// # Compile Error
332///
333/// Fails to compile if path doesn't start with '/'.
334#[macro_export]
335macro_rules! delete {
336 ($path:expr, $handler:expr) => {{
337 const _: &str = $crate::validate_route_path($path);
338 $crate::__delete_impl($path, $handler)
339 }};
340}
341
342/// Internal implementation for DELETE routes (used by the delete! macro)
343#[doc(hidden)]
344pub fn __delete_impl<H, Fut>(path: &'static str, handler: H) -> RouteDefBuilder<H>
345where
346 H: Fn(Request) -> Fut + Send + Sync + 'static,
347 Fut: Future<Output = Response> + Send + 'static,
348{
349 RouteDefBuilder::new(HttpMethod::Delete, path, handler)
350}
351
352// ============================================================================
353// Fallback Route Support
354// ============================================================================
355
356/// Builder for fallback route definitions that supports `.middleware()` chaining
357///
358/// The fallback route is invoked when no other routes match, allowing custom
359/// handling of 404 scenarios.
360pub struct FallbackDefBuilder<H> {
361 handler: H,
362 middlewares: Vec<BoxedMiddleware>,
363}
364
365impl<H, Fut> FallbackDefBuilder<H>
366where
367 H: Fn(Request) -> Fut + Send + Sync + 'static,
368 Fut: Future<Output = Response> + Send + 'static,
369{
370 /// Create a new fallback definition builder
371 pub fn new(handler: H) -> Self {
372 Self {
373 handler,
374 middlewares: Vec::new(),
375 }
376 }
377
378 /// Add middleware to this fallback route
379 pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
380 self.middlewares.push(into_boxed(middleware));
381 self
382 }
383
384 /// Register this fallback definition with a router
385 pub fn register(self, mut router: Router) -> Router {
386 let handler = self.handler;
387 let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
388 router.set_fallback(Arc::new(boxed));
389
390 // Apply middleware
391 for mw in self.middlewares {
392 router.add_fallback_middleware(mw);
393 }
394
395 router
396 }
397}
398
399/// Create a fallback route definition
400///
401/// The fallback handler is called when no other routes match the request,
402/// allowing you to override the default 404 behavior.
403///
404/// # Example
405/// ```rust,ignore
406/// routes! {
407/// get!("/", controllers::home::index),
408/// get!("/users", controllers::user::index),
409///
410/// // Custom 404 handler
411/// fallback!(controllers::fallback::invoke),
412/// }
413/// ```
414///
415/// With middleware:
416/// ```rust,ignore
417/// routes! {
418/// get!("/", controllers::home::index),
419/// fallback!(controllers::fallback::invoke).middleware(LoggingMiddleware),
420/// }
421/// ```
422#[macro_export]
423macro_rules! fallback {
424 ($handler:expr) => {{
425 $crate::__fallback_impl($handler)
426 }};
427}
428
429/// Internal implementation for fallback routes (used by the fallback! macro)
430#[doc(hidden)]
431pub fn __fallback_impl<H, Fut>(handler: H) -> FallbackDefBuilder<H>
432where
433 H: Fn(Request) -> Fut + Send + Sync + 'static,
434 Fut: Future<Output = Response> + Send + 'static,
435{
436 FallbackDefBuilder::new(handler)
437}
438
439// ============================================================================
440// Route Grouping Support
441// ============================================================================
442
443/// A route stored within a group (type-erased handler)
444pub struct GroupRoute {
445 method: HttpMethod,
446 path: &'static str,
447 handler: Arc<BoxedHandler>,
448 name: Option<&'static str>,
449 middlewares: Vec<BoxedMiddleware>,
450 mcp_tool_name: Option<String>,
451 mcp_description: Option<String>,
452 mcp_hint: Option<String>,
453 mcp_hidden: bool,
454}
455
456/// An item that can be added to a route group - either a route or a nested group
457pub enum GroupItem {
458 /// A single route
459 Route(GroupRoute),
460 /// A nested group with its own prefix and middleware
461 NestedGroup(Box<GroupDef>),
462}
463
464/// Trait for types that can be converted into a GroupItem
465pub trait IntoGroupItem {
466 /// Convert this value into a group item for the route builder.
467 fn into_group_item(self) -> GroupItem;
468}
469
470/// MCP defaults inherited from parent groups to child routes
471#[derive(Default, Clone)]
472struct McpDefaults {
473 tool_name: Option<String>,
474 description: Option<String>,
475 hint: Option<String>,
476 hidden: bool,
477}
478
479/// Group definition that collects routes and applies prefix/middleware
480///
481/// Supports nested groups for arbitrary route organization:
482///
483/// ```rust,ignore
484/// routes! {
485/// group!("/api", {
486/// get!("/users", controllers::user::index).name("api.users"),
487/// post!("/users", controllers::user::store),
488/// // Nested groups are supported
489/// group!("/admin", {
490/// get!("/dashboard", controllers::admin::dashboard),
491/// }),
492/// }).middleware(AuthMiddleware),
493/// }
494/// ```
495pub struct GroupDef {
496 prefix: &'static str,
497 items: Vec<GroupItem>,
498 group_middlewares: Vec<BoxedMiddleware>,
499 mcp_tool_name: Option<String>,
500 mcp_description: Option<String>,
501 mcp_hint: Option<String>,
502 mcp_hidden: bool,
503}
504
505impl GroupDef {
506 /// Create a new route group with the given prefix (internal use)
507 ///
508 /// Use the `group!` macro instead for compile-time validation.
509 #[doc(hidden)]
510 pub fn __new_unchecked(prefix: &'static str) -> Self {
511 Self {
512 prefix,
513 items: Vec::new(),
514 group_middlewares: Vec::new(),
515 mcp_tool_name: None,
516 mcp_description: None,
517 mcp_hint: None,
518 mcp_hidden: false,
519 }
520 }
521
522 /// Add an item (route or nested group) to this group
523 ///
524 /// This is the primary method for adding items to a group. It accepts
525 /// anything that implements `IntoGroupItem`, including routes created
526 /// with `get!`, `post!`, etc., and nested groups created with `group!`.
527 #[allow(clippy::should_implement_trait)]
528 pub fn add<I: IntoGroupItem>(mut self, item: I) -> Self {
529 self.items.push(item.into_group_item());
530 self
531 }
532
533 /// Add a route to this group (backward compatibility)
534 ///
535 /// Prefer using `.add()` which accepts both routes and nested groups.
536 pub fn route<H, Fut>(self, route: RouteDefBuilder<H>) -> Self
537 where
538 H: Fn(Request) -> Fut + Send + Sync + 'static,
539 Fut: Future<Output = Response> + Send + 'static,
540 {
541 self.add(route)
542 }
543
544 /// Add middleware to all routes in this group
545 ///
546 /// Middleware is applied in the order it's added.
547 ///
548 /// # Example
549 ///
550 /// ```rust,ignore
551 /// group!("/api", {
552 /// get!("/users", handler),
553 /// }).middleware(AuthMiddleware).middleware(RateLimitMiddleware)
554 /// ```
555 pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
556 self.group_middlewares.push(into_boxed(middleware));
557 self
558 }
559
560 /// Set a default MCP tool name for all routes in this group
561 ///
562 /// Route-level overrides take precedence.
563 pub fn mcp_tool_name(mut self, name: &str) -> Self {
564 self.mcp_tool_name = Some(name.to_string());
565 self
566 }
567
568 /// Set a default MCP description for all routes in this group
569 ///
570 /// Route-level overrides take precedence.
571 pub fn mcp_description(mut self, desc: &str) -> Self {
572 self.mcp_description = Some(desc.to_string());
573 self
574 }
575
576 /// Set a default MCP hint for all routes in this group
577 ///
578 /// Route-level overrides take precedence.
579 pub fn mcp_hint(mut self, hint: &str) -> Self {
580 self.mcp_hint = Some(hint.to_string());
581 self
582 }
583
584 /// Hide all routes in this group from MCP tool discovery
585 ///
586 /// Route-level overrides take precedence.
587 pub fn mcp_hidden(mut self) -> Self {
588 self.mcp_hidden = true;
589 self
590 }
591
592 /// Register all routes in this group with the router
593 ///
594 /// This prepends the group prefix to each route path and applies
595 /// group middleware to all routes. Nested groups are flattened recursively,
596 /// with prefixes concatenated and middleware inherited from parent groups.
597 ///
598 /// # Path Combination
599 ///
600 /// - If route path is "/" (root), the full path is just the group prefix
601 /// - Otherwise, prefix and route path are concatenated
602 ///
603 /// # Middleware Inheritance
604 ///
605 /// Parent group middleware is applied before child group middleware,
606 /// which is applied before route-specific middleware.
607 pub fn register(self, mut router: Router) -> Router {
608 let mcp_defaults = McpDefaults::default();
609 self.register_with_inherited(&mut router, "", &[], &mcp_defaults);
610 router
611 }
612
613 /// Internal recursive registration with inherited prefix and middleware
614 fn register_with_inherited(
615 self,
616 router: &mut Router,
617 parent_prefix: &str,
618 inherited_middleware: &[BoxedMiddleware],
619 inherited_mcp: &McpDefaults,
620 ) {
621 // Build the full prefix for this group
622 let full_prefix = if parent_prefix.is_empty() {
623 self.prefix.to_string()
624 } else {
625 format!("{}{}", parent_prefix, self.prefix)
626 };
627
628 // Combine inherited middleware with this group's middleware
629 // Parent middleware runs first (outer), then this group's middleware
630 let combined_middleware: Vec<BoxedMiddleware> = inherited_middleware
631 .iter()
632 .cloned()
633 .chain(self.group_middlewares.iter().cloned())
634 .collect();
635
636 // Merge MCP defaults: this group's settings override inherited
637 let combined_mcp = McpDefaults {
638 tool_name: self.mcp_tool_name.or(inherited_mcp.tool_name.clone()),
639 description: self.mcp_description.or(inherited_mcp.description.clone()),
640 hint: self.mcp_hint.or(inherited_mcp.hint.clone()),
641 hidden: self.mcp_hidden || inherited_mcp.hidden,
642 };
643
644 for item in self.items {
645 match item {
646 GroupItem::Route(route) => {
647 // Convert :param to {param} for matchit compatibility
648 let converted_route_path = convert_route_params(route.path);
649
650 // Build full path with prefix
651 // If route path is "/" (root), just use the prefix without trailing slash
652 let full_path = if converted_route_path == "/" {
653 if full_prefix.is_empty() {
654 "/".to_string()
655 } else {
656 full_prefix.clone()
657 }
658 } else if full_prefix == "/" {
659 // Prefix is just "/", use route path directly
660 converted_route_path.to_string()
661 } else {
662 format!("{full_prefix}{converted_route_path}")
663 };
664 // We need to leak the string to get a 'static str for the router
665 let full_path: &'static str = Box::leak(full_path.into_boxed_str());
666
667 // Register the route with the router
668 match route.method {
669 HttpMethod::Get => {
670 router.insert_get(full_path, route.handler);
671 }
672 HttpMethod::Post => {
673 router.insert_post(full_path, route.handler);
674 }
675 HttpMethod::Put => {
676 router.insert_put(full_path, route.handler);
677 }
678 HttpMethod::Patch => {
679 router.insert_patch(full_path, route.handler);
680 }
681 HttpMethod::Delete => {
682 router.insert_delete(full_path, route.handler);
683 }
684 }
685
686 // Register route name if present
687 if let Some(name) = route.name {
688 register_route_name(name, full_path);
689 }
690
691 // Apply MCP metadata: route-level overrides group defaults
692 let mcp_tool_name = route.mcp_tool_name.or(combined_mcp.tool_name.clone());
693 let mcp_description =
694 route.mcp_description.or(combined_mcp.description.clone());
695 let mcp_hint = route.mcp_hint.or(combined_mcp.hint.clone());
696 let mcp_hidden = route.mcp_hidden || combined_mcp.hidden;
697
698 if mcp_tool_name.is_some()
699 || mcp_description.is_some()
700 || mcp_hint.is_some()
701 || mcp_hidden
702 {
703 update_route_mcp(
704 full_path,
705 mcp_tool_name,
706 mcp_description,
707 mcp_hint,
708 mcp_hidden,
709 );
710 }
711
712 // Apply combined middleware (inherited + group), then route-specific
713 for mw in &combined_middleware {
714 router.add_middleware(full_path, mw.clone());
715 }
716 for mw in route.middlewares {
717 router.add_middleware(full_path, mw);
718 }
719 }
720 GroupItem::NestedGroup(nested) => {
721 // Recursively register the nested group with accumulated prefix and middleware
722 nested.register_with_inherited(
723 router,
724 &full_prefix,
725 &combined_middleware,
726 &combined_mcp,
727 );
728 }
729 }
730 }
731 }
732}
733
734impl<H, Fut> RouteDefBuilder<H>
735where
736 H: Fn(Request) -> Fut + Send + Sync + 'static,
737 Fut: Future<Output = Response> + Send + 'static,
738{
739 /// Convert this route definition to a type-erased GroupRoute
740 ///
741 /// This is used internally when adding routes to a group.
742 pub fn into_group_route(self) -> GroupRoute {
743 let handler = self.handler;
744 let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
745 GroupRoute {
746 method: self.method,
747 path: self.path,
748 handler: Arc::new(boxed),
749 name: self.name,
750 middlewares: self.middlewares,
751 mcp_tool_name: self.mcp_tool_name,
752 mcp_description: self.mcp_description,
753 mcp_hint: self.mcp_hint,
754 mcp_hidden: self.mcp_hidden,
755 }
756 }
757}
758
759// ============================================================================
760// IntoGroupItem implementations
761// ============================================================================
762
763impl<H, Fut> IntoGroupItem for RouteDefBuilder<H>
764where
765 H: Fn(Request) -> Fut + Send + Sync + 'static,
766 Fut: Future<Output = Response> + Send + 'static,
767{
768 fn into_group_item(self) -> GroupItem {
769 GroupItem::Route(self.into_group_route())
770 }
771}
772
773impl IntoGroupItem for GroupDef {
774 fn into_group_item(self) -> GroupItem {
775 GroupItem::NestedGroup(Box::new(self))
776 }
777}
778
779/// Define a route group with a shared prefix
780///
781/// Routes within a group will have the prefix prepended to their paths.
782/// Middleware can be applied to the entire group using `.middleware()`.
783/// Groups can be nested arbitrarily deep.
784///
785/// # Example
786///
787/// ```rust,ignore
788/// use ferro_rs::{routes, get, post, group};
789///
790/// routes! {
791/// get!("/", controllers::home::index),
792///
793/// // All routes in this group start with /api
794/// group!("/api", {
795/// get!("/users", controllers::user::index), // -> GET /api/users
796/// post!("/users", controllers::user::store), // -> POST /api/users
797///
798/// // Nested groups are supported
799/// group!("/admin", {
800/// get!("/dashboard", controllers::admin::dashboard), // -> GET /api/admin/dashboard
801/// }),
802/// }).middleware(AuthMiddleware), // Applies to ALL routes including nested
803/// }
804/// ```
805///
806/// # Middleware Inheritance
807///
808/// Middleware applied to a parent group is automatically inherited by all nested groups.
809/// The execution order is: parent middleware -> child middleware -> route middleware.
810///
811/// # Compile Error
812///
813/// Fails to compile if prefix doesn't start with '/'.
814#[macro_export]
815macro_rules! group {
816 ($prefix:expr, { $( $item:expr ),* $(,)? }) => {{
817 const _: &str = $crate::validate_route_path($prefix);
818 let mut group = $crate::GroupDef::__new_unchecked($prefix);
819 $(
820 group = group.add($item);
821 )*
822 group
823 }};
824}
825
826/// Define routes with a clean, Laravel-like syntax
827///
828/// This macro generates a `pub fn register() -> Router` function automatically.
829/// Place it at the top level of your `routes.rs` file.
830///
831/// # Example
832/// ```rust,ignore
833/// use ferro_rs::{routes, get, post, put, delete};
834/// use ferro_rs::controllers;
835/// use ferro_rs::middleware::AuthMiddleware;
836///
837/// routes! {
838/// get!("/", controllers::home::index).name("home"),
839/// get!("/users", controllers::user::index).name("users.index"),
840/// get!("/users/{id}", controllers::user::show).name("users.show"),
841/// post!("/users", controllers::user::store).name("users.store"),
842/// put!("/users/{id}", controllers::user::update).name("users.update"),
843/// delete!("/users/{id}", controllers::user::destroy).name("users.destroy"),
844/// get!("/protected", controllers::home::index).middleware(AuthMiddleware),
845/// }
846/// ```
847#[macro_export]
848macro_rules! routes {
849 ( $( $route:expr ),* $(,)? ) => {
850 pub fn register() -> $crate::Router {
851 let mut router = $crate::Router::new();
852 $(
853 router = $route.register(router);
854 )*
855 router
856 }
857 };
858}
859
860// ============================================================================
861// RESTful Resource Routing Support
862// ============================================================================
863
864/// Actions available for resource routing
865#[derive(Clone, Copy, PartialEq, Eq, Debug)]
866pub enum ResourceAction {
867 /// List all resources (GET /resources).
868 Index,
869 /// Show creation form (GET /resources/create).
870 Create,
871 /// Persist a new resource (POST /resources).
872 Store,
873 /// Display a single resource (GET /resources/{id}).
874 Show,
875 /// Show edit form (GET /resources/{id}/edit).
876 Edit,
877 /// Update an existing resource (PUT /resources/{id}).
878 Update,
879 /// Delete a resource (DELETE /resources/{id}).
880 Destroy,
881}
882
883impl ResourceAction {
884 /// Get all available resource actions
885 pub const fn all() -> &'static [ResourceAction] {
886 &[
887 ResourceAction::Index,
888 ResourceAction::Create,
889 ResourceAction::Store,
890 ResourceAction::Show,
891 ResourceAction::Edit,
892 ResourceAction::Update,
893 ResourceAction::Destroy,
894 ]
895 }
896
897 /// Get the HTTP method for this action
898 pub const fn method(&self) -> HttpMethod {
899 match self {
900 ResourceAction::Index => HttpMethod::Get,
901 ResourceAction::Create => HttpMethod::Get,
902 ResourceAction::Store => HttpMethod::Post,
903 ResourceAction::Show => HttpMethod::Get,
904 ResourceAction::Edit => HttpMethod::Get,
905 ResourceAction::Update => HttpMethod::Put,
906 ResourceAction::Destroy => HttpMethod::Delete,
907 }
908 }
909
910 /// Get the path suffix for this action (relative to resource path)
911 pub const fn path_suffix(&self) -> &'static str {
912 match self {
913 ResourceAction::Index => "/",
914 ResourceAction::Create => "/create",
915 ResourceAction::Store => "/",
916 ResourceAction::Show => "/{id}",
917 ResourceAction::Edit => "/{id}/edit",
918 ResourceAction::Update => "/{id}",
919 ResourceAction::Destroy => "/{id}",
920 }
921 }
922
923 /// Get the route name suffix for this action
924 pub const fn name_suffix(&self) -> &'static str {
925 match self {
926 ResourceAction::Index => "index",
927 ResourceAction::Create => "create",
928 ResourceAction::Store => "store",
929 ResourceAction::Show => "show",
930 ResourceAction::Edit => "edit",
931 ResourceAction::Update => "update",
932 ResourceAction::Destroy => "destroy",
933 }
934 }
935}
936
937/// A resource route stored within a ResourceDef (type-erased handler)
938pub struct ResourceRoute {
939 action: ResourceAction,
940 handler: Arc<BoxedHandler>,
941}
942
943/// Resource definition that generates RESTful routes from a controller module
944///
945/// Generates 7 standard routes following Rails/Laravel conventions:
946///
947/// - GET /users -> index (list all)
948/// - GET /users/create -> create (show create form)
949/// - POST /users -> store (create new)
950/// - GET /users/{id} -> show (show one)
951/// - GET /users/{id}/edit -> edit (show edit form)
952/// - PUT /users/{id} -> update (update one)
953/// - DELETE /users/{id} -> destroy (delete one)
954///
955/// Route names are auto-generated: users.index, users.create, etc.
956///
957/// # Example
958///
959/// ```rust,ignore
960/// routes! {
961/// resource!("/users", controllers::user),
962/// resource!("/posts", controllers::post).middleware(AuthMiddleware),
963/// resource!("/comments", controllers::comment, only: [index, show]),
964/// }
965/// ```
966pub struct ResourceDef {
967 prefix: &'static str,
968 routes: Vec<ResourceRoute>,
969 middlewares: Vec<BoxedMiddleware>,
970}
971
972impl ResourceDef {
973 /// Create a new resource definition with no routes (internal use)
974 #[doc(hidden)]
975 pub fn __new_unchecked(prefix: &'static str) -> Self {
976 Self {
977 prefix,
978 routes: Vec::new(),
979 middlewares: Vec::new(),
980 }
981 }
982
983 /// Add a route for a specific action
984 #[doc(hidden)]
985 pub fn __add_route(mut self, action: ResourceAction, handler: Arc<BoxedHandler>) -> Self {
986 self.routes.push(ResourceRoute { action, handler });
987 self
988 }
989
990 /// Add middleware to all routes in this resource
991 ///
992 /// # Example
993 ///
994 /// ```rust,ignore
995 /// resource!("/admin", controllers::admin, only: [index, show])
996 /// .middleware(AuthMiddleware)
997 /// .middleware(AdminMiddleware)
998 /// ```
999 pub fn middleware<M: Middleware + 'static>(mut self, middleware: M) -> Self {
1000 self.middlewares.push(into_boxed(middleware));
1001 self
1002 }
1003
1004 /// Register all resource routes with the router
1005 pub fn register(self, mut router: Router) -> Router {
1006 // Derive route name prefix from path: "/users" -> "users", "/api/users" -> "api.users"
1007 let name_prefix = self.prefix.trim_start_matches('/').replace('/', ".");
1008
1009 for route in self.routes {
1010 let action = route.action;
1011 let path_suffix = action.path_suffix();
1012
1013 // Build full path
1014 let full_path = if path_suffix == "/" {
1015 self.prefix.to_string()
1016 } else {
1017 format!("{}{}", self.prefix, path_suffix)
1018 };
1019 let full_path: &'static str = Box::leak(full_path.into_boxed_str());
1020
1021 // Build route name
1022 let route_name = format!("{}.{}", name_prefix, action.name_suffix());
1023 let route_name: &'static str = Box::leak(route_name.into_boxed_str());
1024
1025 // Register the route
1026 match action.method() {
1027 HttpMethod::Get => {
1028 router.insert_get(full_path, route.handler);
1029 }
1030 HttpMethod::Post => {
1031 router.insert_post(full_path, route.handler);
1032 }
1033 HttpMethod::Put => {
1034 router.insert_put(full_path, route.handler);
1035 }
1036 HttpMethod::Patch => {
1037 router.insert_patch(full_path, route.handler);
1038 }
1039 HttpMethod::Delete => {
1040 router.insert_delete(full_path, route.handler);
1041 }
1042 }
1043
1044 // Register route name
1045 register_route_name(route_name, full_path);
1046
1047 // Apply middleware
1048 for mw in &self.middlewares {
1049 router.add_middleware(full_path, mw.clone());
1050 }
1051 }
1052
1053 router
1054 }
1055}
1056
1057/// Helper function to create a boxed handler from a handler function
1058#[doc(hidden)]
1059pub fn __box_handler<H, Fut>(handler: H) -> Arc<BoxedHandler>
1060where
1061 H: Fn(Request) -> Fut + Send + Sync + 'static,
1062 Fut: Future<Output = Response> + Send + 'static,
1063{
1064 let boxed: BoxedHandler = Box::new(move |req| Box::pin(handler(req)));
1065 Arc::new(boxed)
1066}
1067
1068/// Define RESTful resource routes with convention-over-configuration
1069///
1070/// Generates 7 standard routes following Rails/Laravel conventions from a
1071/// controller module reference.
1072///
1073/// # Convention Mapping
1074///
1075/// | Method | Path | Action | Route Name |
1076/// |--------|-----------------|---------|---------------|
1077/// | GET | /users | index | users.index |
1078/// | GET | /users/create | create | users.create |
1079/// | POST | /users | store | users.store |
1080/// | GET | /users/{id} | show | users.show |
1081/// | GET | /users/{id}/edit| edit | users.edit |
1082/// | PUT | /users/{id} | update | users.update |
1083/// | DELETE | /users/{id} | destroy | users.destroy |
1084///
1085/// # Basic Usage
1086///
1087/// ```rust,ignore
1088/// routes! {
1089/// resource!("/users", controllers::user),
1090/// }
1091/// ```
1092///
1093/// # With Middleware
1094///
1095/// ```rust,ignore
1096/// routes! {
1097/// resource!("/admin", controllers::admin).middleware(AuthMiddleware),
1098/// }
1099/// ```
1100///
1101/// # Subset of Actions
1102///
1103/// Use `only:` to generate only specific routes:
1104///
1105/// ```rust,ignore
1106/// routes! {
1107/// // Only index, show, and store - no create/edit forms, update, or destroy
1108/// resource!("/posts", controllers::post, only: [index, show, store]),
1109/// }
1110/// ```
1111///
1112/// # Path Naming
1113///
1114/// Route names are derived from the path:
1115/// - `/users` → `users.index`, `users.show`, etc.
1116/// - `/api/users` → `api.users.index`, `api.users.show`, etc.
1117///
1118/// # Compile Error
1119///
1120/// Fails to compile if path doesn't start with '/'.
1121#[macro_export]
1122macro_rules! resource {
1123 // Full resource (all 7 routes)
1124 // Note: The module path is followed by path segments to each handler
1125 ($path:expr, $($controller:ident)::+) => {{
1126 const _: &str = $crate::validate_route_path($path);
1127 $crate::ResourceDef::__new_unchecked($path)
1128 .__add_route($crate::ResourceAction::Index, $crate::__box_handler($($controller)::+::index))
1129 .__add_route($crate::ResourceAction::Create, $crate::__box_handler($($controller)::+::create))
1130 .__add_route($crate::ResourceAction::Store, $crate::__box_handler($($controller)::+::store))
1131 .__add_route($crate::ResourceAction::Show, $crate::__box_handler($($controller)::+::show))
1132 .__add_route($crate::ResourceAction::Edit, $crate::__box_handler($($controller)::+::edit))
1133 .__add_route($crate::ResourceAction::Update, $crate::__box_handler($($controller)::+::update))
1134 .__add_route($crate::ResourceAction::Destroy, $crate::__box_handler($($controller)::+::destroy))
1135 }};
1136
1137 // Subset of routes with `only:` parameter
1138 ($path:expr, $($controller:ident)::+, only: [$($action:ident),* $(,)?]) => {{
1139 const _: &str = $crate::validate_route_path($path);
1140 let mut resource = $crate::ResourceDef::__new_unchecked($path);
1141 $(
1142 resource = resource.__add_route(
1143 $crate::__resource_action!($action),
1144 $crate::__box_handler($($controller)::+::$action)
1145 );
1146 )*
1147 resource
1148 }};
1149}
1150
1151/// Internal macro to convert action identifier to ResourceAction enum
1152#[doc(hidden)]
1153#[macro_export]
1154macro_rules! __resource_action {
1155 (index) => {
1156 $crate::ResourceAction::Index
1157 };
1158 (create) => {
1159 $crate::ResourceAction::Create
1160 };
1161 (store) => {
1162 $crate::ResourceAction::Store
1163 };
1164 (show) => {
1165 $crate::ResourceAction::Show
1166 };
1167 (edit) => {
1168 $crate::ResourceAction::Edit
1169 };
1170 (update) => {
1171 $crate::ResourceAction::Update
1172 };
1173 (destroy) => {
1174 $crate::ResourceAction::Destroy
1175 };
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180 use super::*;
1181
1182 #[test]
1183 fn test_convert_route_params() {
1184 // Basic parameter conversion
1185 assert_eq!(convert_route_params("/users/:id"), "/users/{id}");
1186
1187 // Multiple parameters
1188 assert_eq!(
1189 convert_route_params("/posts/:post_id/comments/:id"),
1190 "/posts/{post_id}/comments/{id}"
1191 );
1192
1193 // Already uses matchit syntax - should be unchanged
1194 assert_eq!(convert_route_params("/users/{id}"), "/users/{id}");
1195
1196 // No parameters - should be unchanged
1197 assert_eq!(convert_route_params("/users"), "/users");
1198 assert_eq!(convert_route_params("/"), "/");
1199
1200 // Mixed syntax (edge case)
1201 assert_eq!(
1202 convert_route_params("/users/:user_id/posts/{post_id}"),
1203 "/users/{user_id}/posts/{post_id}"
1204 );
1205
1206 // Parameter at the end
1207 assert_eq!(
1208 convert_route_params("/api/v1/:version"),
1209 "/api/v1/{version}"
1210 );
1211 }
1212
1213 // Helper for creating test handlers
1214 async fn test_handler(_req: Request) -> Response {
1215 crate::http::text("ok")
1216 }
1217
1218 #[test]
1219 fn test_group_item_route() {
1220 // Test that RouteDefBuilder can be converted to GroupItem
1221 let route_builder = RouteDefBuilder::new(HttpMethod::Get, "/test", test_handler);
1222 let item = route_builder.into_group_item();
1223 matches!(item, GroupItem::Route(_));
1224 }
1225
1226 #[test]
1227 fn test_group_item_nested_group() {
1228 // Test that GroupDef can be converted to GroupItem
1229 let group_def = GroupDef::__new_unchecked("/nested");
1230 let item = group_def.into_group_item();
1231 matches!(item, GroupItem::NestedGroup(_));
1232 }
1233
1234 #[test]
1235 fn test_group_add_route() {
1236 // Test adding a route to a group
1237 let group = GroupDef::__new_unchecked("/api").add(RouteDefBuilder::new(
1238 HttpMethod::Get,
1239 "/users",
1240 test_handler,
1241 ));
1242
1243 assert_eq!(group.items.len(), 1);
1244 matches!(&group.items[0], GroupItem::Route(_));
1245 }
1246
1247 #[test]
1248 fn test_group_add_nested_group() {
1249 // Test adding a nested group to a group
1250 let nested = GroupDef::__new_unchecked("/users");
1251 let group = GroupDef::__new_unchecked("/api").add(nested);
1252
1253 assert_eq!(group.items.len(), 1);
1254 matches!(&group.items[0], GroupItem::NestedGroup(_));
1255 }
1256
1257 #[test]
1258 fn test_group_mixed_items() {
1259 // Test adding both routes and nested groups
1260 let nested = GroupDef::__new_unchecked("/admin");
1261 let group = GroupDef::__new_unchecked("/api")
1262 .add(RouteDefBuilder::new(
1263 HttpMethod::Get,
1264 "/users",
1265 test_handler,
1266 ))
1267 .add(nested)
1268 .add(RouteDefBuilder::new(
1269 HttpMethod::Post,
1270 "/users",
1271 test_handler,
1272 ));
1273
1274 assert_eq!(group.items.len(), 3);
1275 matches!(&group.items[0], GroupItem::Route(_));
1276 matches!(&group.items[1], GroupItem::NestedGroup(_));
1277 matches!(&group.items[2], GroupItem::Route(_));
1278 }
1279
1280 #[test]
1281 fn test_deep_nesting() {
1282 // Test deeply nested groups (3 levels)
1283 let level3 = GroupDef::__new_unchecked("/level3").add(RouteDefBuilder::new(
1284 HttpMethod::Get,
1285 "/",
1286 test_handler,
1287 ));
1288
1289 let level2 = GroupDef::__new_unchecked("/level2").add(level3);
1290
1291 let level1 = GroupDef::__new_unchecked("/level1").add(level2);
1292
1293 assert_eq!(level1.items.len(), 1);
1294 if let GroupItem::NestedGroup(l2) = &level1.items[0] {
1295 assert_eq!(l2.items.len(), 1);
1296 if let GroupItem::NestedGroup(l3) = &l2.items[0] {
1297 assert_eq!(l3.items.len(), 1);
1298 } else {
1299 panic!("Expected nested group at level 2");
1300 }
1301 } else {
1302 panic!("Expected nested group at level 1");
1303 }
1304 }
1305
1306 #[test]
1307 fn test_backward_compatibility_route_method() {
1308 // Test that the old .route() method still works
1309 let group = GroupDef::__new_unchecked("/api").route(RouteDefBuilder::new(
1310 HttpMethod::Get,
1311 "/users",
1312 test_handler,
1313 ));
1314
1315 assert_eq!(group.items.len(), 1);
1316 matches!(&group.items[0], GroupItem::Route(_));
1317 }
1318}