Skip to main content

fastapi_output/components/
routing_debug.rs

1//! Routing debug output component.
2//!
3//! Provides detailed visual display of the routing decision process,
4//! showing which routes were considered, why matches succeeded or failed,
5//! parameter extraction results, and middleware that will be applied.
6//!
7//! # Feature Gating
8//!
9//! This module is designed for debug output. In production, routing debug
10//! should only be enabled when explicitly requested.
11//!
12//! ```rust,ignore
13//! if config.debug_routing {
14//!     let debug = RoutingDebug::new(OutputMode::Rich);
15//!     println!("{}", debug.format(&routing_result));
16//! }
17//! ```
18
19use crate::mode::OutputMode;
20use crate::themes::FastApiTheme;
21use std::time::Duration;
22
23const ANSI_RESET: &str = "\x1b[0m";
24const ANSI_BOLD: &str = "\x1b[1m";
25
26/// Result of a route matching attempt.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum MatchResult {
29    /// Route matched successfully.
30    Matched,
31    /// Route did not match - path mismatch.
32    PathMismatch,
33    /// Route did not match - method mismatch.
34    MethodMismatch,
35    /// Route did not match - parameter type validation failed.
36    ParamTypeMismatch {
37        /// Name of the parameter that failed.
38        param_name: String,
39        /// Expected type.
40        expected_type: String,
41        /// Actual value that didn't match.
42        actual_value: String,
43    },
44    /// Route did not match - guard/condition failed.
45    GuardFailed {
46        /// Name of the guard that failed.
47        guard_name: String,
48    },
49}
50
51impl MatchResult {
52    /// Get a human-readable description of the result.
53    #[must_use]
54    pub fn description(&self) -> String {
55        match self {
56            Self::Matched => "Matched".to_string(),
57            Self::PathMismatch => "Path did not match".to_string(),
58            Self::MethodMismatch => "Method not allowed".to_string(),
59            Self::ParamTypeMismatch {
60                param_name,
61                expected_type,
62                actual_value,
63            } => {
64                format!("Parameter '{param_name}' expected {expected_type}, got '{actual_value}'")
65            }
66            Self::GuardFailed { guard_name } => format!("Guard '{guard_name}' failed"),
67        }
68    }
69
70    /// Check if this is a successful match.
71    #[must_use]
72    pub fn is_match(&self) -> bool {
73        matches!(self, Self::Matched)
74    }
75}
76
77/// Information about a candidate route that was considered.
78#[derive(Debug, Clone)]
79pub struct CandidateRoute {
80    /// Route pattern (e.g., "/users/{id}").
81    pub pattern: String,
82    /// Allowed HTTP methods for this route.
83    pub methods: Vec<String>,
84    /// Handler function name.
85    pub handler: Option<String>,
86    /// Match result for this candidate.
87    pub result: MatchResult,
88    /// Whether this route partially matched (path ok, method wrong).
89    pub partial_match: bool,
90}
91
92impl CandidateRoute {
93    /// Create a new candidate route.
94    #[must_use]
95    pub fn new(pattern: impl Into<String>, result: MatchResult) -> Self {
96        Self {
97            pattern: pattern.into(),
98            methods: Vec::new(),
99            handler: None,
100            result,
101            partial_match: false,
102        }
103    }
104
105    /// Set the allowed methods.
106    #[must_use]
107    pub fn methods(mut self, methods: impl IntoIterator<Item = impl Into<String>>) -> Self {
108        self.methods = methods.into_iter().map(Into::into).collect();
109        self
110    }
111
112    /// Set the handler name.
113    #[must_use]
114    pub fn handler(mut self, handler: impl Into<String>) -> Self {
115        self.handler = Some(handler.into());
116        self
117    }
118
119    /// Mark as partial match.
120    #[must_use]
121    pub fn partial_match(mut self, partial: bool) -> Self {
122        self.partial_match = partial;
123        self
124    }
125}
126
127/// Extracted path parameters.
128#[derive(Debug, Clone)]
129pub struct ExtractedParams {
130    /// Parameter name to extracted value.
131    pub params: Vec<(String, String)>,
132}
133
134impl ExtractedParams {
135    /// Create new extracted params.
136    #[must_use]
137    pub fn new() -> Self {
138        Self { params: Vec::new() }
139    }
140
141    /// Add a parameter.
142    #[must_use]
143    pub fn param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
144        self.params.push((name.into(), value.into()));
145        self
146    }
147
148    /// Check if empty.
149    #[must_use]
150    pub fn is_empty(&self) -> bool {
151        self.params.is_empty()
152    }
153}
154
155impl Default for ExtractedParams {
156    fn default() -> Self {
157        Self::new()
158    }
159}
160
161/// Information about middleware that will be applied.
162#[derive(Debug, Clone)]
163pub struct MiddlewareInfo {
164    /// Middleware name.
165    pub name: String,
166    /// Whether this middleware is route-specific.
167    pub route_specific: bool,
168    /// Order in the middleware stack.
169    pub order: usize,
170}
171
172impl MiddlewareInfo {
173    /// Create new middleware info.
174    #[must_use]
175    pub fn new(name: impl Into<String>, order: usize) -> Self {
176        Self {
177            name: name.into(),
178            route_specific: false,
179            order,
180        }
181    }
182
183    /// Mark as route-specific.
184    #[must_use]
185    pub fn route_specific(mut self, specific: bool) -> Self {
186        self.route_specific = specific;
187        self
188    }
189}
190
191/// Complete routing debug information.
192#[derive(Debug, Clone)]
193pub struct RoutingDebugInfo {
194    /// The request path being routed.
195    pub request_path: String,
196    /// The request method.
197    pub request_method: String,
198    /// All candidate routes that were considered.
199    pub candidates: Vec<CandidateRoute>,
200    /// The matched route (if any).
201    pub matched_route: Option<String>,
202    /// Extracted path parameters.
203    pub extracted_params: ExtractedParams,
204    /// Middleware that will be applied.
205    pub middleware: Vec<MiddlewareInfo>,
206    /// Time taken to route (in microseconds).
207    pub routing_time: Option<Duration>,
208    /// Whether any routes partially matched (405 scenario).
209    pub has_partial_matches: bool,
210}
211
212impl RoutingDebugInfo {
213    /// Create new routing debug info.
214    #[must_use]
215    pub fn new(path: impl Into<String>, method: impl Into<String>) -> Self {
216        Self {
217            request_path: path.into(),
218            request_method: method.into(),
219            candidates: Vec::new(),
220            matched_route: None,
221            extracted_params: ExtractedParams::new(),
222            middleware: Vec::new(),
223            routing_time: None,
224            has_partial_matches: false,
225        }
226    }
227
228    /// Add a candidate route.
229    #[must_use]
230    pub fn candidate(mut self, candidate: CandidateRoute) -> Self {
231        if candidate.partial_match {
232            self.has_partial_matches = true;
233        }
234        if candidate.result.is_match() {
235            self.matched_route = Some(candidate.pattern.clone());
236        }
237        self.candidates.push(candidate);
238        self
239    }
240
241    /// Set extracted parameters.
242    #[must_use]
243    pub fn params(mut self, params: ExtractedParams) -> Self {
244        self.extracted_params = params;
245        self
246    }
247
248    /// Add middleware info.
249    #[must_use]
250    pub fn middleware(mut self, mw: MiddlewareInfo) -> Self {
251        self.middleware.push(mw);
252        self
253    }
254
255    /// Set routing time.
256    #[must_use]
257    pub fn routing_time(mut self, duration: Duration) -> Self {
258        self.routing_time = Some(duration);
259        self
260    }
261
262    /// Check if routing was successful.
263    #[must_use]
264    pub fn is_matched(&self) -> bool {
265        self.matched_route.is_some()
266    }
267}
268
269/// Routing debug output formatter.
270#[derive(Debug, Clone)]
271pub struct RoutingDebug {
272    mode: OutputMode,
273    theme: FastApiTheme,
274    /// Show all candidates or just relevant ones.
275    pub show_all_candidates: bool,
276    /// Show middleware stack.
277    pub show_middleware: bool,
278}
279
280impl RoutingDebug {
281    /// Create a new routing debug formatter.
282    #[must_use]
283    pub fn new(mode: OutputMode) -> Self {
284        Self {
285            mode,
286            theme: FastApiTheme::default(),
287            show_all_candidates: true,
288            show_middleware: true,
289        }
290    }
291
292    /// Set the theme.
293    #[must_use]
294    pub fn theme(mut self, theme: FastApiTheme) -> Self {
295        self.theme = theme;
296        self
297    }
298
299    /// Format routing debug information.
300    #[must_use]
301    pub fn format(&self, info: &RoutingDebugInfo) -> String {
302        match self.mode {
303            OutputMode::Plain => self.format_plain(info),
304            OutputMode::Minimal => self.format_minimal(info),
305            OutputMode::Rich => self.format_rich(info),
306        }
307    }
308
309    fn format_plain(&self, info: &RoutingDebugInfo) -> String {
310        let mut lines = Vec::new();
311
312        // Header
313        lines.push("=== Routing Debug ===".to_string());
314        lines.push(format!(
315            "Request: {} {}",
316            info.request_method, info.request_path
317        ));
318
319        if let Some(duration) = info.routing_time {
320            lines.push(format!("Routing time: {}", format_duration(duration)));
321        }
322
323        // Result summary
324        lines.push(String::new());
325        if let Some(matched) = &info.matched_route {
326            lines.push(format!("Result: MATCHED -> {matched}"));
327        } else if info.has_partial_matches {
328            lines.push("Result: 405 Method Not Allowed".to_string());
329        } else {
330            lines.push("Result: 404 Not Found".to_string());
331        }
332
333        // Candidates
334        if self.show_all_candidates && !info.candidates.is_empty() {
335            lines.push(String::new());
336            lines.push("Candidates considered:".to_string());
337            for candidate in &info.candidates {
338                let status = if candidate.result.is_match() {
339                    "[MATCH]"
340                } else if candidate.partial_match {
341                    "[PARTIAL]"
342                } else {
343                    "[SKIP]"
344                };
345                let methods = candidate.methods.join(", ");
346                lines.push(format!("  {status} {} [{methods}]", candidate.pattern));
347                if !candidate.result.is_match() {
348                    lines.push(format!(
349                        "        Reason: {}",
350                        candidate.result.description()
351                    ));
352                }
353            }
354        }
355
356        // Extracted parameters
357        if !info.extracted_params.is_empty() {
358            lines.push(String::new());
359            lines.push("Extracted parameters:".to_string());
360            for (name, value) in &info.extracted_params.params {
361                lines.push(format!("  {name}: {value}"));
362            }
363        }
364
365        // Middleware
366        if self.show_middleware && !info.middleware.is_empty() {
367            lines.push(String::new());
368            lines.push("Middleware stack:".to_string());
369            for mw in &info.middleware {
370                let scope = if mw.route_specific {
371                    "(route)"
372                } else {
373                    "(global)"
374                };
375                lines.push(format!("  {}. {} {scope}", mw.order, mw.name));
376            }
377        }
378
379        lines.push("=====================".to_string());
380        lines.join("\n")
381    }
382
383    fn format_minimal(&self, info: &RoutingDebugInfo) -> String {
384        let muted = self.theme.muted.to_ansi_fg();
385        let accent = self.theme.accent.to_ansi_fg();
386        let success = self.theme.success.to_ansi_fg();
387        let error = self.theme.error.to_ansi_fg();
388        let warning = self.theme.warning.to_ansi_fg();
389
390        let mut lines = Vec::new();
391
392        // Header
393        lines.push(format!("{muted}=== Routing Debug ==={ANSI_RESET}"));
394        let method_color = self.method_color(&info.request_method).to_ansi_fg();
395        lines.push(format!(
396            "{method_color}{}{ANSI_RESET} {accent}{}{ANSI_RESET}",
397            info.request_method, info.request_path
398        ));
399
400        // Result
401        if let Some(matched) = &info.matched_route {
402            lines.push(format!("{success}✓ Matched:{ANSI_RESET} {matched}"));
403        } else if info.has_partial_matches {
404            lines.push(format!("{warning}⚠ 405 Method Not Allowed{ANSI_RESET}"));
405        } else {
406            lines.push(format!("{error}✗ 404 Not Found{ANSI_RESET}"));
407        }
408
409        // Timing
410        if let Some(duration) = info.routing_time {
411            lines.push(format!(
412                "{muted}Routed in {}{ANSI_RESET}",
413                format_duration(duration)
414            ));
415        }
416
417        // Extracted parameters
418        if !info.extracted_params.is_empty() {
419            lines.push(format!("{muted}Parameters:{ANSI_RESET}"));
420            for (name, value) in &info.extracted_params.params {
421                lines.push(format!("  {accent}{name}{ANSI_RESET}: {value}"));
422            }
423        }
424
425        lines.push(format!("{muted}=================={ANSI_RESET}"));
426        lines.join("\n")
427    }
428
429    #[allow(clippy::too_many_lines)]
430    fn format_rich(&self, info: &RoutingDebugInfo) -> String {
431        let muted = self.theme.muted.to_ansi_fg();
432        let accent = self.theme.accent.to_ansi_fg();
433        let success = self.theme.success.to_ansi_fg();
434        let error = self.theme.error.to_ansi_fg();
435        let warning = self.theme.warning.to_ansi_fg();
436        let border = self.theme.border.to_ansi_fg();
437        let header_style = self.theme.header.to_ansi_fg();
438
439        let mut lines = Vec::new();
440
441        // Top border
442        lines.push(format!(
443            "{border}┌─────────────────────────────────────────────┐{ANSI_RESET}"
444        ));
445        lines.push(format!(
446            "{border}│{ANSI_RESET} {header_style}{ANSI_BOLD}Routing Debug{ANSI_RESET}                                {border}│{ANSI_RESET}"
447        ));
448        lines.push(format!(
449            "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
450        ));
451
452        // Request line
453        let method_bg = self.method_color(&info.request_method).to_ansi_bg();
454        lines.push(format!(
455            "{border}│{ANSI_RESET} {method_bg}{ANSI_BOLD} {} {ANSI_RESET} {accent}{}{ANSI_RESET}",
456            info.request_method, info.request_path
457        ));
458
459        // Result row
460        lines.push(format!(
461            "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
462        ));
463        if let Some(matched) = &info.matched_route {
464            lines.push(format!(
465                "{border}│{ANSI_RESET} {success}✓ Matched{ANSI_RESET} → {matched}"
466            ));
467        } else if info.has_partial_matches {
468            lines.push(format!(
469                "{border}│{ANSI_RESET} {warning}⚠ 405 Method Not Allowed{ANSI_RESET}"
470            ));
471            // Show allowed methods
472            let allowed: Vec<_> = info
473                .candidates
474                .iter()
475                .filter(|c| c.partial_match)
476                .flat_map(|c| c.methods.iter())
477                .collect();
478            if !allowed.is_empty() {
479                lines.push(format!(
480                    "{border}│{ANSI_RESET}   {muted}Allowed:{ANSI_RESET} {}",
481                    allowed.into_iter().cloned().collect::<Vec<_>>().join(", ")
482                ));
483            }
484        } else {
485            lines.push(format!(
486                "{border}│{ANSI_RESET} {error}✗ 404 Not Found{ANSI_RESET}"
487            ));
488        }
489
490        // Timing
491        if let Some(duration) = info.routing_time {
492            lines.push(format!(
493                "{border}│{ANSI_RESET} {muted}Routed in {}{ANSI_RESET}",
494                format_duration(duration)
495            ));
496        }
497
498        // Candidates section
499        if self.show_all_candidates && !info.candidates.is_empty() {
500            lines.push(format!(
501                "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
502            ));
503            lines.push(format!(
504                "{border}│{ANSI_RESET} {header_style}Candidates{ANSI_RESET} {muted}({}){ANSI_RESET}",
505                info.candidates.len()
506            ));
507
508            for candidate in &info.candidates {
509                let (icon, color) = if candidate.result.is_match() {
510                    ("✓", &success)
511                } else if candidate.partial_match {
512                    ("◐", &warning)
513                } else {
514                    ("○", &muted)
515                };
516                let methods = candidate.methods.join("|");
517                lines.push(format!(
518                    "{border}│{ANSI_RESET}   {color}{icon}{ANSI_RESET} {}{} {muted}[{methods}]{ANSI_RESET}",
519                    candidate.pattern,
520                    if let Some(h) = &candidate.handler {
521                        format!(" {muted}→ {h}{ANSI_RESET}")
522                    } else {
523                        String::new()
524                    }
525                ));
526                if !candidate.result.is_match()
527                    && !matches!(candidate.result, MatchResult::PathMismatch)
528                {
529                    lines.push(format!(
530                        "{border}│{ANSI_RESET}     {muted}{}{ANSI_RESET}",
531                        candidate.result.description()
532                    ));
533                }
534            }
535        }
536
537        // Extracted parameters
538        if !info.extracted_params.is_empty() {
539            lines.push(format!(
540                "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
541            ));
542            lines.push(format!(
543                "{border}│{ANSI_RESET} {header_style}Extracted Parameters{ANSI_RESET}"
544            ));
545            for (name, value) in &info.extracted_params.params {
546                lines.push(format!(
547                    "{border}│{ANSI_RESET}   {accent}{name}{ANSI_RESET}: {value}"
548                ));
549            }
550        }
551
552        // Middleware stack
553        if self.show_middleware && !info.middleware.is_empty() {
554            lines.push(format!(
555                "{border}├─────────────────────────────────────────────┤{ANSI_RESET}"
556            ));
557            lines.push(format!(
558                "{border}│{ANSI_RESET} {header_style}Middleware Stack{ANSI_RESET}"
559            ));
560            for mw in &info.middleware {
561                let scope = if mw.route_specific {
562                    format!("{accent}(route){ANSI_RESET}")
563                } else {
564                    format!("{muted}(global){ANSI_RESET}")
565                };
566                lines.push(format!(
567                    "{border}│{ANSI_RESET}   {muted}{}→{ANSI_RESET} {} {scope}",
568                    mw.order, mw.name
569                ));
570            }
571        }
572
573        // Bottom border
574        lines.push(format!(
575            "{border}└─────────────────────────────────────────────┘{ANSI_RESET}"
576        ));
577
578        lines.join("\n")
579    }
580
581    fn method_color(&self, method: &str) -> crate::themes::Color {
582        match method.to_uppercase().as_str() {
583            "GET" => self.theme.http_get,
584            "POST" => self.theme.http_post,
585            "PUT" => self.theme.http_put,
586            "DELETE" => self.theme.http_delete,
587            "PATCH" => self.theme.http_patch,
588            "OPTIONS" => self.theme.http_options,
589            "HEAD" => self.theme.http_head,
590            _ => self.theme.muted,
591        }
592    }
593}
594
595/// Format a duration in human-readable form.
596fn format_duration(duration: Duration) -> String {
597    let micros = duration.as_micros();
598    if micros < 1000 {
599        format!("{micros}µs")
600    } else if micros < 1_000_000 {
601        let whole = micros / 1000;
602        let frac = (micros % 1000) / 10;
603        format!("{whole}.{frac:02}ms")
604    } else {
605        let whole = micros / 1_000_000;
606        let frac = (micros % 1_000_000) / 10_000;
607        format!("{whole}.{frac:02}s")
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    fn sample_successful_routing() -> RoutingDebugInfo {
616        RoutingDebugInfo::new("/api/users/42", "GET")
617            .candidate(
618                CandidateRoute::new("/api/health", MatchResult::PathMismatch)
619                    .methods(["GET"])
620                    .handler("health_check"),
621            )
622            .candidate(
623                CandidateRoute::new("/api/users", MatchResult::PathMismatch)
624                    .methods(["GET", "POST"])
625                    .handler("list_users"),
626            )
627            .candidate(
628                CandidateRoute::new("/api/users/{id}", MatchResult::Matched)
629                    .methods(["GET", "PUT", "DELETE"])
630                    .handler("get_user"),
631            )
632            .params(ExtractedParams::new().param("id", "42"))
633            .middleware(MiddlewareInfo::new("RequestLogger", 1))
634            .middleware(MiddlewareInfo::new("Auth", 2))
635            .middleware(MiddlewareInfo::new("RateLimit", 3).route_specific(true))
636            .routing_time(Duration::from_micros(45))
637    }
638
639    fn sample_404_routing() -> RoutingDebugInfo {
640        RoutingDebugInfo::new("/api/nonexistent", "GET")
641            .candidate(
642                CandidateRoute::new("/api/users", MatchResult::PathMismatch)
643                    .methods(["GET"])
644                    .handler("list_users"),
645            )
646            .candidate(
647                CandidateRoute::new("/api/items", MatchResult::PathMismatch)
648                    .methods(["GET"])
649                    .handler("list_items"),
650            )
651            .routing_time(Duration::from_micros(12))
652    }
653
654    fn sample_405_routing() -> RoutingDebugInfo {
655        RoutingDebugInfo::new("/api/users", "DELETE")
656            .candidate(
657                CandidateRoute::new("/api/users", MatchResult::MethodMismatch)
658                    .methods(["GET", "POST"])
659                    .handler("list_users")
660                    .partial_match(true),
661            )
662            .routing_time(Duration::from_micros(8))
663    }
664
665    #[test]
666    fn test_match_result_description() {
667        assert_eq!(MatchResult::Matched.description(), "Matched");
668        assert_eq!(
669            MatchResult::PathMismatch.description(),
670            "Path did not match"
671        );
672        assert_eq!(
673            MatchResult::ParamTypeMismatch {
674                param_name: "id".to_string(),
675                expected_type: "int".to_string(),
676                actual_value: "abc".to_string(),
677            }
678            .description(),
679            "Parameter 'id' expected int, got 'abc'"
680        );
681    }
682
683    #[test]
684    fn test_routing_debug_plain_success() {
685        let debug = RoutingDebug::new(OutputMode::Plain);
686        let output = debug.format(&sample_successful_routing());
687
688        assert!(output.contains("Routing Debug"));
689        assert!(output.contains("GET /api/users/42"));
690        assert!(output.contains("MATCHED"));
691        assert!(output.contains("/api/users/{id}"));
692        assert!(output.contains("id: 42"));
693        assert!(output.contains("RequestLogger"));
694        assert!(!output.contains("\x1b["));
695    }
696
697    #[test]
698    fn test_routing_debug_plain_404() {
699        let debug = RoutingDebug::new(OutputMode::Plain);
700        let output = debug.format(&sample_404_routing());
701
702        assert!(output.contains("404 Not Found"));
703        assert!(!output.contains("MATCHED"));
704    }
705
706    #[test]
707    fn test_routing_debug_plain_405() {
708        let debug = RoutingDebug::new(OutputMode::Plain);
709        let output = debug.format(&sample_405_routing());
710
711        assert!(output.contains("405 Method Not Allowed"));
712        assert!(output.contains("[PARTIAL]"));
713    }
714
715    #[test]
716    fn test_routing_debug_rich_has_ansi() {
717        let debug = RoutingDebug::new(OutputMode::Rich);
718        let output = debug.format(&sample_successful_routing());
719
720        assert!(output.contains("\x1b["));
721        assert!(output.contains("✓ Matched"));
722    }
723
724    #[test]
725    fn test_extracted_params_builder() {
726        let params = ExtractedParams::new()
727            .param("id", "42")
728            .param("name", "alice");
729
730        assert_eq!(params.params.len(), 2);
731        assert!(!params.is_empty());
732    }
733
734    #[test]
735    fn test_candidate_route_builder() {
736        let candidate = CandidateRoute::new("/api/users/{id}", MatchResult::Matched)
737            .methods(["GET", "PUT"])
738            .handler("get_user")
739            .partial_match(false);
740
741        assert_eq!(candidate.pattern, "/api/users/{id}");
742        assert_eq!(candidate.methods, vec!["GET", "PUT"]);
743        assert!(candidate.result.is_match());
744    }
745
746    #[test]
747    fn test_middleware_info_builder() {
748        let mw = MiddlewareInfo::new("Auth", 1).route_specific(true);
749
750        assert_eq!(mw.name, "Auth");
751        assert_eq!(mw.order, 1);
752        assert!(mw.route_specific);
753    }
754}