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