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