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
164impl Default for MethodRouter {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170/// Create a GET route handler
171pub fn get<H, T>(handler: H) -> MethodRouter
172where
173    H: Handler<T>,
174    T: 'static,
175{
176    let mut op = Operation::new();
177    H::update_operation(&mut op);
178    MethodRouter::new().on(Method::GET, into_boxed_handler(handler), op)
179}
180
181/// Create a POST route handler
182pub fn post<H, T>(handler: H) -> MethodRouter
183where
184    H: Handler<T>,
185    T: 'static,
186{
187    let mut op = Operation::new();
188    H::update_operation(&mut op);
189    MethodRouter::new().on(Method::POST, into_boxed_handler(handler), op)
190}
191
192/// Create a PUT route handler
193pub fn put<H, T>(handler: H) -> MethodRouter
194where
195    H: Handler<T>,
196    T: 'static,
197{
198    let mut op = Operation::new();
199    H::update_operation(&mut op);
200    MethodRouter::new().on(Method::PUT, into_boxed_handler(handler), op)
201}
202
203/// Create a PATCH route handler
204pub fn patch<H, T>(handler: H) -> MethodRouter
205where
206    H: Handler<T>,
207    T: 'static,
208{
209    let mut op = Operation::new();
210    H::update_operation(&mut op);
211    MethodRouter::new().on(Method::PATCH, into_boxed_handler(handler), op)
212}
213
214/// Create a DELETE route handler
215pub fn delete<H, T>(handler: H) -> MethodRouter
216where
217    H: Handler<T>,
218    T: 'static,
219{
220    let mut op = Operation::new();
221    H::update_operation(&mut op);
222    MethodRouter::new().on(Method::DELETE, into_boxed_handler(handler), op)
223}
224
225/// Main router
226pub struct Router {
227    inner: MatchitRouter<MethodRouter>,
228    state: Arc<Extensions>,
229    /// Track registered routes for conflict detection
230    registered_routes: HashMap<String, RouteInfo>,
231}
232
233impl Router {
234    /// Create a new router
235    pub fn new() -> Self {
236        Self {
237            inner: MatchitRouter::new(),
238            state: Arc::new(Extensions::new()),
239            registered_routes: HashMap::new(),
240        }
241    }
242
243    /// Add a route
244    pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
245        // Convert {param} style to :param for matchit
246        let matchit_path = convert_path_params(path);
247
248        // Get the methods being registered
249        let methods: Vec<Method> = method_router.handlers.keys().cloned().collect();
250
251        match self.inner.insert(matchit_path.clone(), method_router) {
252            Ok(_) => {
253                // Track the registered route
254                self.registered_routes.insert(
255                    matchit_path.clone(),
256                    RouteInfo {
257                        path: path.to_string(),
258                        methods,
259                    },
260                );
261            }
262            Err(e) => {
263                // Find the existing conflicting route
264                let existing_path = self
265                    .find_conflicting_route(&matchit_path)
266                    .map(|info| info.path.clone())
267                    .unwrap_or_else(|| "<unknown>".to_string());
268
269                let conflict_error = RouteConflictError {
270                    new_path: path.to_string(),
271                    method: methods.first().cloned(),
272                    existing_path,
273                    details: e.to_string(),
274                };
275
276                panic!("{}", conflict_error);
277            }
278        }
279        self
280    }
281
282    /// Find a conflicting route by checking registered routes
283    fn find_conflicting_route(&self, matchit_path: &str) -> Option<&RouteInfo> {
284        // Try to find an exact match first
285        if let Some(info) = self.registered_routes.get(matchit_path) {
286            return Some(info);
287        }
288
289        // Try to find a route that would conflict (same structure but different param names)
290        let normalized_new = normalize_path_for_comparison(matchit_path);
291
292        for (registered_path, info) in &self.registered_routes {
293            let normalized_existing = normalize_path_for_comparison(registered_path);
294            if normalized_new == normalized_existing {
295                return Some(info);
296            }
297        }
298
299        None
300    }
301
302    /// Add application state
303    pub fn state<S: Clone + Send + Sync + 'static>(mut self, state: S) -> Self {
304        let extensions = Arc::make_mut(&mut self.state);
305        extensions.insert(state);
306        self
307    }
308
309    /// Nest another router under a prefix
310    pub fn nest(self, _prefix: &str, _router: Router) -> Self {
311        // TODO: Implement router nesting
312        self
313    }
314
315    /// Match a request and return the handler + params
316    pub(crate) fn match_route(&self, path: &str, method: &Method) -> RouteMatch<'_> {
317        match self.inner.at(path) {
318            Ok(matched) => {
319                let method_router = matched.value;
320
321                if let Some(handler) = method_router.get_handler(method) {
322                    // Convert params to HashMap
323                    let params: HashMap<String, String> = matched
324                        .params
325                        .iter()
326                        .map(|(k, v)| (k.to_string(), v.to_string()))
327                        .collect();
328
329                    RouteMatch::Found { handler, params }
330                } else {
331                    RouteMatch::MethodNotAllowed {
332                        allowed: method_router.allowed_methods(),
333                    }
334                }
335            }
336            Err(_) => RouteMatch::NotFound,
337        }
338    }
339
340    /// Get shared state
341    pub(crate) fn state_ref(&self) -> Arc<Extensions> {
342        self.state.clone()
343    }
344
345    /// Get registered routes (for testing and debugging)
346    pub fn registered_routes(&self) -> &HashMap<String, RouteInfo> {
347        &self.registered_routes
348    }
349}
350
351impl Default for Router {
352    fn default() -> Self {
353        Self::new()
354    }
355}
356
357/// Result of route matching
358pub(crate) enum RouteMatch<'a> {
359    Found {
360        handler: &'a BoxedHandler,
361        params: HashMap<String, String>,
362    },
363    NotFound,
364    MethodNotAllowed {
365        allowed: Vec<Method>,
366    },
367}
368
369/// Convert {param} style to :param for matchit
370fn convert_path_params(path: &str) -> String {
371    let mut result = String::with_capacity(path.len());
372
373    for ch in path.chars() {
374        match ch {
375            '{' => {
376                result.push(':');
377            }
378            '}' => {
379                // Skip closing brace
380            }
381            _ => {
382                result.push(ch);
383            }
384        }
385    }
386
387    result
388}
389
390/// Normalize a path for conflict comparison by replacing parameter names with a placeholder
391fn normalize_path_for_comparison(path: &str) -> String {
392    let mut result = String::with_capacity(path.len());
393    let mut in_param = false;
394
395    for ch in path.chars() {
396        match ch {
397            ':' => {
398                in_param = true;
399                result.push_str(":_");
400            }
401            '/' => {
402                in_param = false;
403                result.push('/');
404            }
405            _ if in_param => {
406                // Skip parameter name characters
407            }
408            _ => {
409                result.push(ch);
410            }
411        }
412    }
413
414    result
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_convert_path_params() {
423        assert_eq!(convert_path_params("/users/{id}"), "/users/:id");
424        assert_eq!(
425            convert_path_params("/users/{user_id}/posts/{post_id}"),
426            "/users/:user_id/posts/:post_id"
427        );
428        assert_eq!(convert_path_params("/static/path"), "/static/path");
429    }
430
431    #[test]
432    fn test_normalize_path_for_comparison() {
433        assert_eq!(normalize_path_for_comparison("/users/:id"), "/users/:_");
434        assert_eq!(
435            normalize_path_for_comparison("/users/:user_id"),
436            "/users/:_"
437        );
438        assert_eq!(
439            normalize_path_for_comparison("/users/:id/posts/:post_id"),
440            "/users/:_/posts/:_"
441        );
442        assert_eq!(
443            normalize_path_for_comparison("/static/path"),
444            "/static/path"
445        );
446    }
447
448    #[test]
449    #[should_panic(expected = "ROUTE CONFLICT DETECTED")]
450    fn test_route_conflict_detection() {
451        async fn handler1() -> &'static str {
452            "handler1"
453        }
454        async fn handler2() -> &'static str {
455            "handler2"
456        }
457
458        let _router = Router::new()
459            .route("/users/{id}", get(handler1))
460            .route("/users/{user_id}", get(handler2)); // This should panic
461    }
462
463    #[test]
464    fn test_no_conflict_different_paths() {
465        async fn handler1() -> &'static str {
466            "handler1"
467        }
468        async fn handler2() -> &'static str {
469            "handler2"
470        }
471
472        let router = Router::new()
473            .route("/users/{id}", get(handler1))
474            .route("/users/{id}/profile", get(handler2));
475
476        assert_eq!(router.registered_routes().len(), 2);
477    }
478
479    #[test]
480    fn test_route_info_tracking() {
481        async fn handler() -> &'static str {
482            "handler"
483        }
484
485        let router = Router::new().route("/users/{id}", get(handler));
486
487        let routes = router.registered_routes();
488        assert_eq!(routes.len(), 1);
489
490        let info = routes.get("/users/:id").unwrap();
491        assert_eq!(info.path, "/users/{id}");
492        assert_eq!(info.methods.len(), 1);
493        assert_eq!(info.methods[0], Method::GET);
494    }
495}
496
497#[cfg(test)]
498mod property_tests {
499    use super::*;
500    use proptest::prelude::*;
501    use std::panic::{catch_unwind, AssertUnwindSafe};
502
503    // **Feature: phase4-ergonomics-v1, Property 1: Route Conflict Detection**
504    //
505    // For any two routes with the same path and HTTP method registered on the same
506    // RustApi instance, the system should detect the conflict and report an error
507    // at startup time.
508    //
509    // **Validates: Requirements 1.2**
510    proptest! {
511        #![proptest_config(ProptestConfig::with_cases(100))]
512
513        /// Property: Routes with identical path structure but different parameter names conflict
514        ///
515        /// For any valid path with parameters, registering two routes with the same
516        /// structure but different parameter names should be detected as a conflict.
517        #[test]
518        fn prop_same_structure_different_param_names_conflict(
519            // Generate valid path segments
520            segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4),
521            // Generate two different parameter names
522            param1 in "[a-z][a-z0-9]{0,5}",
523            param2 in "[a-z][a-z0-9]{0,5}",
524        ) {
525            // Ensure param names are different
526            prop_assume!(param1 != param2);
527
528            // Build two paths with same structure but different param names
529            let mut path1 = String::from("/");
530            let mut path2 = String::from("/");
531
532            for segment in &segments {
533                path1.push_str(segment);
534                path1.push('/');
535                path2.push_str(segment);
536                path2.push('/');
537            }
538
539            path1.push('{');
540            path1.push_str(&param1);
541            path1.push('}');
542
543            path2.push('{');
544            path2.push_str(&param2);
545            path2.push('}');
546
547            // Try to register both routes - should panic
548            let result = catch_unwind(AssertUnwindSafe(|| {
549                async fn handler1() -> &'static str { "handler1" }
550                async fn handler2() -> &'static str { "handler2" }
551
552                let _router = Router::new()
553                    .route(&path1, get(handler1))
554                    .route(&path2, get(handler2));
555            }));
556
557            prop_assert!(
558                result.is_err(),
559                "Routes '{}' and '{}' should conflict but didn't",
560                path1, path2
561            );
562        }
563
564        /// Property: Routes with different path structures don't conflict
565        ///
566        /// For any two paths with different structures (different number of segments
567        /// or different static segments), they should not conflict.
568        #[test]
569        fn prop_different_structures_no_conflict(
570            // Generate different path segments for two routes
571            segments1 in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
572            segments2 in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
573            // Optional parameter at the end
574            has_param1 in any::<bool>(),
575            has_param2 in any::<bool>(),
576        ) {
577            // Build two paths
578            let mut path1 = String::from("/");
579            let mut path2 = String::from("/");
580
581            for segment in &segments1 {
582                path1.push_str(segment);
583                path1.push('/');
584            }
585            path1.pop(); // Remove trailing slash
586
587            for segment in &segments2 {
588                path2.push_str(segment);
589                path2.push('/');
590            }
591            path2.pop(); // Remove trailing slash
592
593            if has_param1 {
594                path1.push_str("/{id}");
595            }
596
597            if has_param2 {
598                path2.push_str("/{id}");
599            }
600
601            // Normalize paths for comparison
602            let norm1 = normalize_path_for_comparison(&convert_path_params(&path1));
603            let norm2 = normalize_path_for_comparison(&convert_path_params(&path2));
604
605            // Only test if paths are actually different
606            prop_assume!(norm1 != norm2);
607
608            // Try to register both routes - should succeed
609            let result = catch_unwind(AssertUnwindSafe(|| {
610                async fn handler1() -> &'static str { "handler1" }
611                async fn handler2() -> &'static str { "handler2" }
612
613                let router = Router::new()
614                    .route(&path1, get(handler1))
615                    .route(&path2, get(handler2));
616
617                router.registered_routes().len()
618            }));
619
620            prop_assert!(
621                result.is_ok(),
622                "Routes '{}' and '{}' should not conflict but did",
623                path1, path2
624            );
625
626            if let Ok(count) = result {
627                prop_assert_eq!(count, 2, "Should have registered 2 routes");
628            }
629        }
630
631        /// Property: Conflict error message contains both route paths
632        ///
633        /// When a conflict is detected, the error message should include both
634        /// the existing route path and the new conflicting route path.
635        #[test]
636        fn prop_conflict_error_contains_both_paths(
637            // Generate a valid path segment
638            segment in "[a-z][a-z0-9]{1,5}",
639            param1 in "[a-z][a-z0-9]{1,5}",
640            param2 in "[a-z][a-z0-9]{1,5}",
641        ) {
642            prop_assume!(param1 != param2);
643
644            let path1 = format!("/{}/{{{}}}", segment, param1);
645            let path2 = format!("/{}/{{{}}}", segment, param2);
646
647            let result = catch_unwind(AssertUnwindSafe(|| {
648                async fn handler1() -> &'static str { "handler1" }
649                async fn handler2() -> &'static str { "handler2" }
650
651                let _router = Router::new()
652                    .route(&path1, get(handler1))
653                    .route(&path2, get(handler2));
654            }));
655
656            prop_assert!(result.is_err(), "Should have panicked due to conflict");
657
658            // Check that the panic message contains useful information
659            if let Err(panic_info) = result {
660                if let Some(msg) = panic_info.downcast_ref::<String>() {
661                    prop_assert!(
662                        msg.contains("ROUTE CONFLICT DETECTED"),
663                        "Error should contain 'ROUTE CONFLICT DETECTED', got: {}",
664                        msg
665                    );
666                    prop_assert!(
667                        msg.contains("Existing:") && msg.contains("New:"),
668                        "Error should contain both 'Existing:' and 'New:' labels, got: {}",
669                        msg
670                    );
671                    prop_assert!(
672                        msg.contains("How to resolve:"),
673                        "Error should contain resolution guidance, got: {}",
674                        msg
675                    );
676                }
677            }
678        }
679
680        /// Property: Exact duplicate paths conflict
681        ///
682        /// Registering the exact same path twice should always be detected as a conflict.
683        #[test]
684        fn prop_exact_duplicate_paths_conflict(
685            // Generate valid path segments
686            segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4),
687            has_param in any::<bool>(),
688        ) {
689            // Build a path
690            let mut path = String::from("/");
691
692            for segment in &segments {
693                path.push_str(segment);
694                path.push('/');
695            }
696            path.pop(); // Remove trailing slash
697
698            if has_param {
699                path.push_str("/{id}");
700            }
701
702            // Try to register the same path twice - should panic
703            let result = catch_unwind(AssertUnwindSafe(|| {
704                async fn handler1() -> &'static str { "handler1" }
705                async fn handler2() -> &'static str { "handler2" }
706
707                let _router = Router::new()
708                    .route(&path, get(handler1))
709                    .route(&path, get(handler2));
710            }));
711
712            prop_assert!(
713                result.is_err(),
714                "Registering path '{}' twice should conflict but didn't",
715                path
716            );
717        }
718    }
719}