rustapi_core/
router.rs

1//! Router implementation using radix tree (matchit)
2//!
3//! This module provides HTTP routing functionality for RustAPI. Routes are
4//! registered using path patterns and HTTP method handlers.
5//!
6//! # Path Patterns
7//!
8//! Routes support dynamic path parameters using `{param}` syntax:
9//!
10//! - `/users` - Static path
11//! - `/users/{id}` - Single parameter
12//! - `/users/{user_id}/posts/{post_id}` - Multiple parameters
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use rustapi_core::{Router, get, post, put, delete};
18//!
19//! async fn list_users() -> &'static str { "List users" }
20//! async fn get_user() -> &'static str { "Get user" }
21//! async fn create_user() -> &'static str { "Create user" }
22//! async fn update_user() -> &'static str { "Update user" }
23//! async fn delete_user() -> &'static str { "Delete user" }
24//!
25//! let router = Router::new()
26//!     .route("/users", get(list_users).post(create_user))
27//!     .route("/users/{id}", get(get_user).put(update_user).delete(delete_user));
28//! ```
29//!
30//! # Method Chaining
31//!
32//! Multiple HTTP methods can be registered for the same path using method chaining:
33//!
34//! ```rust,ignore
35//! .route("/users", get(list).post(create))
36//! .route("/users/{id}", get(show).put(update).delete(destroy))
37//! ```
38//!
39//! # Route Conflict Detection
40//!
41//! The router detects conflicting routes at registration time and provides
42//! helpful error messages with resolution guidance.
43
44use crate::handler::{into_boxed_handler, BoxedHandler, Handler};
45use http::{Extensions, Method};
46use matchit::Router as MatchitRouter;
47use rustapi_openapi::Operation;
48use std::collections::HashMap;
49use std::sync::Arc;
50
51/// Information about a registered route for conflict detection
52#[derive(Debug, Clone)]
53pub struct RouteInfo {
54    /// The original path pattern (e.g., "/users/{id}")
55    pub path: String,
56    /// The HTTP methods registered for this path
57    pub methods: Vec<Method>,
58}
59
60/// Error returned when a route conflict is detected
61#[derive(Debug, Clone)]
62pub struct RouteConflictError {
63    /// The path that was being registered
64    pub new_path: String,
65    /// The HTTP method that conflicts
66    pub method: Option<Method>,
67    /// The existing path that conflicts
68    pub existing_path: String,
69    /// Detailed error message from the underlying router
70    pub details: String,
71}
72
73impl std::fmt::Display for RouteConflictError {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        writeln!(
76            f,
77            "\n╭─────────────────────────────────────────────────────────────╮"
78        )?;
79        writeln!(
80            f,
81            "│                    ROUTE CONFLICT DETECTED                   │"
82        )?;
83        writeln!(
84            f,
85            "╰─────────────────────────────────────────────────────────────╯"
86        )?;
87        writeln!(f)?;
88        writeln!(f, "  Conflicting routes:")?;
89        writeln!(f, "    → Existing: {}", self.existing_path)?;
90        writeln!(f, "    → New:      {}", self.new_path)?;
91        writeln!(f)?;
92        if let Some(ref method) = self.method {
93            writeln!(f, "  HTTP Method: {}", method)?;
94            writeln!(f)?;
95        }
96        writeln!(f, "  Details: {}", self.details)?;
97        writeln!(f)?;
98        writeln!(f, "  How to resolve:")?;
99        writeln!(f, "    1. Use different path patterns for each route")?;
100        writeln!(
101            f,
102            "    2. If paths must be similar, ensure parameter names differ"
103        )?;
104        writeln!(
105            f,
106            "    3. Consider using different HTTP methods if appropriate"
107        )?;
108        writeln!(f)?;
109        writeln!(f, "  Example:")?;
110        writeln!(f, "    Instead of:")?;
111        writeln!(f, "      .route(\"/users/{{id}}\", get(handler1))")?;
112        writeln!(f, "      .route(\"/users/{{user_id}}\", get(handler2))")?;
113        writeln!(f)?;
114        writeln!(f, "    Use:")?;
115        writeln!(f, "      .route(\"/users/{{id}}\", get(handler1))")?;
116        writeln!(f, "      .route(\"/users/{{id}}/profile\", get(handler2))")?;
117        Ok(())
118    }
119}
120
121impl std::error::Error for RouteConflictError {}
122
123/// HTTP method router for a single path
124pub struct MethodRouter {
125    handlers: HashMap<Method, BoxedHandler>,
126    pub(crate) operations: HashMap<Method, Operation>,
127}
128
129impl MethodRouter {
130    /// Create a new empty method router
131    pub fn new() -> Self {
132        Self {
133            handlers: HashMap::new(),
134            operations: HashMap::new(),
135        }
136    }
137
138    /// Add a handler for a specific method
139    fn on(mut self, method: Method, handler: BoxedHandler, operation: Operation) -> Self {
140        self.handlers.insert(method.clone(), handler);
141        self.operations.insert(method, operation);
142        self
143    }
144
145    /// Get handler for a method
146    pub(crate) fn get_handler(&self, method: &Method) -> Option<&BoxedHandler> {
147        self.handlers.get(method)
148    }
149
150    /// Get allowed methods for 405 response
151    pub(crate) fn allowed_methods(&self) -> Vec<Method> {
152        self.handlers.keys().cloned().collect()
153    }
154
155    /// Create from pre-boxed handlers (internal use)
156    pub(crate) fn from_boxed(handlers: HashMap<Method, BoxedHandler>) -> Self {
157        Self {
158            handlers,
159            operations: HashMap::new(), // Operations lost when using raw boxed handlers for now
160        }
161    }
162
163    /// Insert a pre-boxed handler and its OpenAPI operation (internal use).
164    ///
165    /// Panics if the same method is inserted twice for the same path.
166    pub(crate) fn insert_boxed_with_operation(
167        &mut self,
168        method: Method,
169        handler: BoxedHandler,
170        operation: Operation,
171    ) {
172        if self.handlers.contains_key(&method) {
173            panic!(
174                "Duplicate handler for method {} on the same path",
175                method.as_str()
176            );
177        }
178
179        self.handlers.insert(method.clone(), handler);
180        self.operations.insert(method, operation);
181    }
182}
183
184impl Default for MethodRouter {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190/// Create a GET route handler
191pub fn get<H, T>(handler: H) -> MethodRouter
192where
193    H: Handler<T>,
194    T: 'static,
195{
196    let mut op = Operation::new();
197    H::update_operation(&mut op);
198    MethodRouter::new().on(Method::GET, into_boxed_handler(handler), op)
199}
200
201/// Create a POST route handler
202pub fn post<H, T>(handler: H) -> MethodRouter
203where
204    H: Handler<T>,
205    T: 'static,
206{
207    let mut op = Operation::new();
208    H::update_operation(&mut op);
209    MethodRouter::new().on(Method::POST, into_boxed_handler(handler), op)
210}
211
212/// Create a PUT route handler
213pub fn put<H, T>(handler: H) -> MethodRouter
214where
215    H: Handler<T>,
216    T: 'static,
217{
218    let mut op = Operation::new();
219    H::update_operation(&mut op);
220    MethodRouter::new().on(Method::PUT, into_boxed_handler(handler), op)
221}
222
223/// Create a PATCH route handler
224pub fn patch<H, T>(handler: H) -> MethodRouter
225where
226    H: Handler<T>,
227    T: 'static,
228{
229    let mut op = Operation::new();
230    H::update_operation(&mut op);
231    MethodRouter::new().on(Method::PATCH, into_boxed_handler(handler), op)
232}
233
234/// Create a DELETE route handler
235pub fn delete<H, T>(handler: H) -> MethodRouter
236where
237    H: Handler<T>,
238    T: 'static,
239{
240    let mut op = Operation::new();
241    H::update_operation(&mut op);
242    MethodRouter::new().on(Method::DELETE, into_boxed_handler(handler), op)
243}
244
245/// Main router
246pub struct Router {
247    inner: MatchitRouter<MethodRouter>,
248    state: Arc<Extensions>,
249    /// Track registered routes for conflict detection
250    registered_routes: HashMap<String, RouteInfo>,
251}
252
253impl Router {
254    /// Create a new router
255    pub fn new() -> Self {
256        Self {
257            inner: MatchitRouter::new(),
258            state: Arc::new(Extensions::new()),
259            registered_routes: HashMap::new(),
260        }
261    }
262
263    /// Add a route
264    pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
265        // Convert {param} style to :param for matchit
266        let matchit_path = convert_path_params(path);
267
268        // Get the methods being registered
269        let methods: Vec<Method> = method_router.handlers.keys().cloned().collect();
270
271        match self.inner.insert(matchit_path.clone(), method_router) {
272            Ok(_) => {
273                // Track the registered route
274                self.registered_routes.insert(
275                    matchit_path.clone(),
276                    RouteInfo {
277                        path: path.to_string(),
278                        methods,
279                    },
280                );
281            }
282            Err(e) => {
283                // Find the existing conflicting route
284                let existing_path = self
285                    .find_conflicting_route(&matchit_path)
286                    .map(|info| info.path.clone())
287                    .unwrap_or_else(|| "<unknown>".to_string());
288
289                let conflict_error = RouteConflictError {
290                    new_path: path.to_string(),
291                    method: methods.first().cloned(),
292                    existing_path,
293                    details: e.to_string(),
294                };
295
296                panic!("{}", conflict_error);
297            }
298        }
299        self
300    }
301
302    /// Find a conflicting route by checking registered routes
303    fn find_conflicting_route(&self, matchit_path: &str) -> Option<&RouteInfo> {
304        // Try to find an exact match first
305        if let Some(info) = self.registered_routes.get(matchit_path) {
306            return Some(info);
307        }
308
309        // Try to find a route that would conflict (same structure but different param names)
310        let normalized_new = normalize_path_for_comparison(matchit_path);
311
312        for (registered_path, info) in &self.registered_routes {
313            let normalized_existing = normalize_path_for_comparison(registered_path);
314            if normalized_new == normalized_existing {
315                return Some(info);
316            }
317        }
318
319        None
320    }
321
322    /// Add application state
323    pub fn state<S: Clone + Send + Sync + 'static>(mut self, state: S) -> Self {
324        let extensions = Arc::make_mut(&mut self.state);
325        extensions.insert(state);
326        self
327    }
328
329    /// Nest another router under a prefix
330    pub fn nest(self, _prefix: &str, _router: Router) -> Self {
331        // TODO: Implement router nesting
332        self
333    }
334
335    /// Match a request and return the handler + params
336    pub(crate) fn match_route(&self, path: &str, method: &Method) -> RouteMatch<'_> {
337        match self.inner.at(path) {
338            Ok(matched) => {
339                let method_router = matched.value;
340
341                if let Some(handler) = method_router.get_handler(method) {
342                    // Convert params to HashMap
343                    let params: HashMap<String, String> = matched
344                        .params
345                        .iter()
346                        .map(|(k, v)| (k.to_string(), v.to_string()))
347                        .collect();
348
349                    RouteMatch::Found { handler, params }
350                } else {
351                    RouteMatch::MethodNotAllowed {
352                        allowed: method_router.allowed_methods(),
353                    }
354                }
355            }
356            Err(_) => RouteMatch::NotFound,
357        }
358    }
359
360    /// Get shared state
361    pub(crate) fn state_ref(&self) -> Arc<Extensions> {
362        self.state.clone()
363    }
364
365    /// Get registered routes (for testing and debugging)
366    pub fn registered_routes(&self) -> &HashMap<String, RouteInfo> {
367        &self.registered_routes
368    }
369}
370
371impl Default for Router {
372    fn default() -> Self {
373        Self::new()
374    }
375}
376
377/// Result of route matching
378pub(crate) enum RouteMatch<'a> {
379    Found {
380        handler: &'a BoxedHandler,
381        params: HashMap<String, String>,
382    },
383    NotFound,
384    MethodNotAllowed {
385        allowed: Vec<Method>,
386    },
387}
388
389/// Convert {param} style to :param for matchit
390fn convert_path_params(path: &str) -> String {
391    let mut result = String::with_capacity(path.len());
392
393    for ch in path.chars() {
394        match ch {
395            '{' => {
396                result.push(':');
397            }
398            '}' => {
399                // Skip closing brace
400            }
401            _ => {
402                result.push(ch);
403            }
404        }
405    }
406
407    result
408}
409
410/// Normalize a path for conflict comparison by replacing parameter names with a placeholder
411fn normalize_path_for_comparison(path: &str) -> String {
412    let mut result = String::with_capacity(path.len());
413    let mut in_param = false;
414
415    for ch in path.chars() {
416        match ch {
417            ':' => {
418                in_param = true;
419                result.push_str(":_");
420            }
421            '/' => {
422                in_param = false;
423                result.push('/');
424            }
425            _ if in_param => {
426                // Skip parameter name characters
427            }
428            _ => {
429                result.push(ch);
430            }
431        }
432    }
433
434    result
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn test_convert_path_params() {
443        assert_eq!(convert_path_params("/users/{id}"), "/users/:id");
444        assert_eq!(
445            convert_path_params("/users/{user_id}/posts/{post_id}"),
446            "/users/:user_id/posts/:post_id"
447        );
448        assert_eq!(convert_path_params("/static/path"), "/static/path");
449    }
450
451    #[test]
452    fn test_normalize_path_for_comparison() {
453        assert_eq!(normalize_path_for_comparison("/users/:id"), "/users/:_");
454        assert_eq!(
455            normalize_path_for_comparison("/users/:user_id"),
456            "/users/:_"
457        );
458        assert_eq!(
459            normalize_path_for_comparison("/users/:id/posts/:post_id"),
460            "/users/:_/posts/:_"
461        );
462        assert_eq!(
463            normalize_path_for_comparison("/static/path"),
464            "/static/path"
465        );
466    }
467
468    #[test]
469    #[should_panic(expected = "ROUTE CONFLICT DETECTED")]
470    fn test_route_conflict_detection() {
471        async fn handler1() -> &'static str {
472            "handler1"
473        }
474        async fn handler2() -> &'static str {
475            "handler2"
476        }
477
478        let _router = Router::new()
479            .route("/users/{id}", get(handler1))
480            .route("/users/{user_id}", get(handler2)); // This should panic
481    }
482
483    #[test]
484    fn test_no_conflict_different_paths() {
485        async fn handler1() -> &'static str {
486            "handler1"
487        }
488        async fn handler2() -> &'static str {
489            "handler2"
490        }
491
492        let router = Router::new()
493            .route("/users/{id}", get(handler1))
494            .route("/users/{id}/profile", get(handler2));
495
496        assert_eq!(router.registered_routes().len(), 2);
497    }
498
499    #[test]
500    fn test_route_info_tracking() {
501        async fn handler() -> &'static str {
502            "handler"
503        }
504
505        let router = Router::new().route("/users/{id}", get(handler));
506
507        let routes = router.registered_routes();
508        assert_eq!(routes.len(), 1);
509
510        let info = routes.get("/users/:id").unwrap();
511        assert_eq!(info.path, "/users/{id}");
512        assert_eq!(info.methods.len(), 1);
513        assert_eq!(info.methods[0], Method::GET);
514    }
515}
516
517#[cfg(test)]
518mod property_tests {
519    use super::*;
520    use proptest::prelude::*;
521    use std::panic::{catch_unwind, AssertUnwindSafe};
522
523    // **Feature: phase4-ergonomics-v1, Property 1: Route Conflict Detection**
524    //
525    // For any two routes with the same path and HTTP method registered on the same
526    // RustApi instance, the system should detect the conflict and report an error
527    // at startup time.
528    //
529    // **Validates: Requirements 1.2**
530    proptest! {
531        #![proptest_config(ProptestConfig::with_cases(100))]
532
533        /// Property: Routes with identical path structure but different parameter names conflict
534        ///
535        /// For any valid path with parameters, registering two routes with the same
536        /// structure but different parameter names should be detected as a conflict.
537        #[test]
538        fn prop_same_structure_different_param_names_conflict(
539            // Generate valid path segments
540            segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4),
541            // Generate two different parameter names
542            param1 in "[a-z][a-z0-9]{0,5}",
543            param2 in "[a-z][a-z0-9]{0,5}",
544        ) {
545            // Ensure param names are different
546            prop_assume!(param1 != param2);
547
548            // Build two paths with same structure but different param names
549            let mut path1 = String::from("/");
550            let mut path2 = String::from("/");
551
552            for segment in &segments {
553                path1.push_str(segment);
554                path1.push('/');
555                path2.push_str(segment);
556                path2.push('/');
557            }
558
559            path1.push('{');
560            path1.push_str(&param1);
561            path1.push('}');
562
563            path2.push('{');
564            path2.push_str(&param2);
565            path2.push('}');
566
567            // Try to register both routes - should panic
568            let result = catch_unwind(AssertUnwindSafe(|| {
569                async fn handler1() -> &'static str { "handler1" }
570                async fn handler2() -> &'static str { "handler2" }
571
572                let _router = Router::new()
573                    .route(&path1, get(handler1))
574                    .route(&path2, get(handler2));
575            }));
576
577            prop_assert!(
578                result.is_err(),
579                "Routes '{}' and '{}' should conflict but didn't",
580                path1, path2
581            );
582        }
583
584        /// Property: Routes with different path structures don't conflict
585        ///
586        /// For any two paths with different structures (different number of segments
587        /// or different static segments), they should not conflict.
588        #[test]
589        fn prop_different_structures_no_conflict(
590            // Generate different path segments for two routes
591            segments1 in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
592            segments2 in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
593            // Optional parameter at the end
594            has_param1 in any::<bool>(),
595            has_param2 in any::<bool>(),
596        ) {
597            // Build two paths
598            let mut path1 = String::from("/");
599            let mut path2 = String::from("/");
600
601            for segment in &segments1 {
602                path1.push_str(segment);
603                path1.push('/');
604            }
605            path1.pop(); // Remove trailing slash
606
607            for segment in &segments2 {
608                path2.push_str(segment);
609                path2.push('/');
610            }
611            path2.pop(); // Remove trailing slash
612
613            if has_param1 {
614                path1.push_str("/{id}");
615            }
616
617            if has_param2 {
618                path2.push_str("/{id}");
619            }
620
621            // Normalize paths for comparison
622            let norm1 = normalize_path_for_comparison(&convert_path_params(&path1));
623            let norm2 = normalize_path_for_comparison(&convert_path_params(&path2));
624
625            // Only test if paths are actually different
626            prop_assume!(norm1 != norm2);
627
628            // Try to register both routes - should succeed
629            let result = catch_unwind(AssertUnwindSafe(|| {
630                async fn handler1() -> &'static str { "handler1" }
631                async fn handler2() -> &'static str { "handler2" }
632
633                let router = Router::new()
634                    .route(&path1, get(handler1))
635                    .route(&path2, get(handler2));
636
637                router.registered_routes().len()
638            }));
639
640            prop_assert!(
641                result.is_ok(),
642                "Routes '{}' and '{}' should not conflict but did",
643                path1, path2
644            );
645
646            if let Ok(count) = result {
647                prop_assert_eq!(count, 2, "Should have registered 2 routes");
648            }
649        }
650
651        /// Property: Conflict error message contains both route paths
652        ///
653        /// When a conflict is detected, the error message should include both
654        /// the existing route path and the new conflicting route path.
655        #[test]
656        fn prop_conflict_error_contains_both_paths(
657            // Generate a valid path segment
658            segment in "[a-z][a-z0-9]{1,5}",
659            param1 in "[a-z][a-z0-9]{1,5}",
660            param2 in "[a-z][a-z0-9]{1,5}",
661        ) {
662            prop_assume!(param1 != param2);
663
664            let path1 = format!("/{}/{{{}}}", segment, param1);
665            let path2 = format!("/{}/{{{}}}", segment, param2);
666
667            let result = catch_unwind(AssertUnwindSafe(|| {
668                async fn handler1() -> &'static str { "handler1" }
669                async fn handler2() -> &'static str { "handler2" }
670
671                let _router = Router::new()
672                    .route(&path1, get(handler1))
673                    .route(&path2, get(handler2));
674            }));
675
676            prop_assert!(result.is_err(), "Should have panicked due to conflict");
677
678            // Check that the panic message contains useful information
679            if let Err(panic_info) = result {
680                if let Some(msg) = panic_info.downcast_ref::<String>() {
681                    prop_assert!(
682                        msg.contains("ROUTE CONFLICT DETECTED"),
683                        "Error should contain 'ROUTE CONFLICT DETECTED', got: {}",
684                        msg
685                    );
686                    prop_assert!(
687                        msg.contains("Existing:") && msg.contains("New:"),
688                        "Error should contain both 'Existing:' and 'New:' labels, got: {}",
689                        msg
690                    );
691                    prop_assert!(
692                        msg.contains("How to resolve:"),
693                        "Error should contain resolution guidance, got: {}",
694                        msg
695                    );
696                }
697            }
698        }
699
700        /// Property: Exact duplicate paths conflict
701        ///
702        /// Registering the exact same path twice should always be detected as a conflict.
703        #[test]
704        fn prop_exact_duplicate_paths_conflict(
705            // Generate valid path segments
706            segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4),
707            has_param in any::<bool>(),
708        ) {
709            // Build a path
710            let mut path = String::from("/");
711
712            for segment in &segments {
713                path.push_str(segment);
714                path.push('/');
715            }
716            path.pop(); // Remove trailing slash
717
718            if has_param {
719                path.push_str("/{id}");
720            }
721
722            // Try to register the same path twice - should panic
723            let result = catch_unwind(AssertUnwindSafe(|| {
724                async fn handler1() -> &'static str { "handler1" }
725                async fn handler2() -> &'static str { "handler2" }
726
727                let _router = Router::new()
728                    .route(&path, get(handler1))
729                    .route(&path, get(handler2));
730            }));
731
732            prop_assert!(
733                result.is_err(),
734                "Registering path '{}' twice should conflict but didn't",
735                path
736            );
737        }
738    }
739}