Skip to main content

rustapi_core/
app.rs

1//! RustApi application builder
2
3use crate::error::Result;
4use crate::events::LifecycleHooks;
5use crate::interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor};
6use crate::middleware::{
7    BodyLimitLayer, BoxedNext, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT,
8};
9use crate::response::IntoResponse;
10use crate::router::{MethodRouter, Router};
11use crate::server::Server;
12use crate::{Request, Response};
13use http::Extensions;
14use std::collections::BTreeMap;
15#[cfg(feature = "dashboard")]
16use std::collections::BTreeSet;
17use std::future::Future;
18use std::sync::Arc;
19use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
20
21/// A dispatcher that can drive requests through the RustAPI pipeline
22/// (interceptors + layers + router) without any network or serialization overhead.
23///
24/// Obtained via [`RustApi::request_dispatcher`].
25#[derive(Clone)]
26pub struct RequestDispatcher {
27    router: Arc<Router>,
28    layers: LayerStack,
29    interceptors: InterceptorChain,
30}
31
32impl RequestDispatcher {
33    /// Returns the shared state Extensions from the underlying router.
34    /// Useful for in-process request construction to preserve `State<T>` etc.
35    pub fn state_ref(&self) -> Arc<Extensions> {
36        self.router.state_ref()
37    }
38
39    /// Dispatch a request through the full stack (interceptors → middleware layers
40    /// → route handler → response interceptors).
41    ///
42    /// This replicates the logic used by the normal HTTP server.
43    pub async fn dispatch(&self, request: Request) -> Response {
44        let req = self.interceptors.intercept_request(request);
45
46        let path = req.path().to_owned();
47        let method = req.method().clone();
48
49        let response = if self.layers.is_empty() {
50            crate::server::route_request_direct(&self.router, req, &path, &method).await
51        } else {
52            let router = self.router.clone();
53            let p = path.clone();
54            let m = method.clone();
55
56            let routing_handler: BoxedNext = Arc::new(move |r: Request| {
57                let router = router.clone();
58                let pp = p.clone();
59                let mm = m.clone();
60                Box::pin(async move { crate::server::route_request(&router, r, &pp, &mm).await })
61            });
62
63            self.layers.execute(req, routing_handler).await
64        };
65
66        self.interceptors.intercept_response(response)
67    }
68}
69
70/// Main application builder for RustAPI
71///
72/// # Example
73///
74/// ```rust,ignore
75/// use rustapi_rs::prelude::*;
76///
77/// #[tokio::main]
78/// async fn main() -> Result<()> {
79///     RustApi::new()
80///         .state(AppState::new())
81///         .route("/", get(hello))
82///         .route("/users/{id}", get(get_user))
83///         .run("127.0.0.1:8080")
84///         .await
85/// }
86/// ```
87pub struct RustApi {
88    router: Router,
89    openapi_spec: rustapi_openapi::OpenApiSpec,
90    layers: LayerStack,
91    body_limit: Option<usize>,
92    interceptors: InterceptorChain,
93    lifecycle_hooks: LifecycleHooks,
94    hot_reload: bool,
95    #[cfg(feature = "http3")]
96    http3_config: Option<crate::http3::Http3Config>,
97    health_check: Option<crate::health::HealthCheck>,
98    health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
99    status_config: Option<crate::status::StatusConfig>,
100    #[cfg(feature = "dashboard")]
101    dashboard_config: Option<crate::dashboard::DashboardConfig>,
102}
103
104/// Configuration for RustAPI's built-in production baseline preset.
105///
106/// This preset bundles together the most common foundation pieces for a
107/// production HTTP service:
108/// - request IDs on every response
109/// - structured tracing spans with service metadata
110/// - standard `/health`, `/ready`, and `/live` probes
111#[derive(Debug, Clone)]
112pub struct ProductionDefaultsConfig {
113    service_name: String,
114    version: Option<String>,
115    tracing_level: tracing::Level,
116    health_endpoint_config: Option<crate::health::HealthEndpointConfig>,
117    enable_request_id: bool,
118    enable_tracing: bool,
119    enable_health_endpoints: bool,
120}
121
122impl ProductionDefaultsConfig {
123    /// Create a new production baseline configuration.
124    pub fn new(service_name: impl Into<String>) -> Self {
125        Self {
126            service_name: service_name.into(),
127            version: None,
128            tracing_level: tracing::Level::INFO,
129            health_endpoint_config: None,
130            enable_request_id: true,
131            enable_tracing: true,
132            enable_health_endpoints: true,
133        }
134    }
135
136    /// Annotate tracing spans and default health payloads with an application version.
137    pub fn version(mut self, version: impl Into<String>) -> Self {
138        self.version = Some(version.into());
139        self
140    }
141
142    /// Set the tracing log level used by the preset tracing layer.
143    pub fn tracing_level(mut self, level: tracing::Level) -> Self {
144        self.tracing_level = level;
145        self
146    }
147
148    /// Override the default health endpoint paths.
149    pub fn health_endpoint_config(mut self, config: crate::health::HealthEndpointConfig) -> Self {
150        self.health_endpoint_config = Some(config);
151        self
152    }
153
154    /// Enable or disable request ID propagation.
155    pub fn request_id(mut self, enabled: bool) -> Self {
156        self.enable_request_id = enabled;
157        self
158    }
159
160    /// Enable or disable structured tracing middleware.
161    pub fn tracing(mut self, enabled: bool) -> Self {
162        self.enable_tracing = enabled;
163        self
164    }
165
166    /// Enable or disable built-in health endpoints.
167    pub fn health_endpoints(mut self, enabled: bool) -> Self {
168        self.enable_health_endpoints = enabled;
169        self
170    }
171}
172
173impl RustApi {
174    /// Create a new RustAPI application
175    pub fn new() -> Self {
176        // Initialize tracing if not already done
177        let _ = tracing_subscriber::registry()
178            .with(
179                EnvFilter::try_from_default_env()
180                    .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
181            )
182            .with(tracing_subscriber::fmt::layer())
183            .try_init();
184
185        Self {
186            router: Router::new(),
187            openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
188                .register::<rustapi_openapi::ErrorSchema>()
189                .register::<rustapi_openapi::ErrorBodySchema>()
190                .register::<rustapi_openapi::ValidationErrorSchema>()
191                .register::<rustapi_openapi::ValidationErrorBodySchema>()
192                .register::<rustapi_openapi::FieldErrorSchema>(),
193            layers: LayerStack::new(),
194            body_limit: Some(DEFAULT_BODY_LIMIT), // Default 1MB limit
195            interceptors: InterceptorChain::new(),
196            lifecycle_hooks: LifecycleHooks::new(),
197            hot_reload: false,
198            #[cfg(feature = "http3")]
199            http3_config: None,
200            health_check: None,
201            health_endpoint_config: None,
202            status_config: None,
203            #[cfg(feature = "dashboard")]
204            dashboard_config: None,
205        }
206    }
207
208    /// The primary way to build a RustAPI application.
209    ///
210    /// Collects all routes decorated with `#[rustapi_rs::get]`, `#[rustapi_rs::post]`, etc.
211    /// at link time via `linkme` and registers them automatically — no manual `.route()`
212    /// or `.mount_route()` calls needed. This is baked into the core and requires no
213    /// feature flags.
214    ///
215    /// When the `swagger-ui` feature is enabled (included in the default `core` feature),
216    /// Swagger UI is served at `/docs`. Without it, only the auto-discovered routes are
217    /// registered.
218    ///
219    /// Use [`RustApi::new()`] when handlers are plain `async fn` not annotated with
220    /// the route macros, or when you need full manual control over route registration.
221    ///
222    /// # Example
223    ///
224    /// ```rust,ignore
225    /// use rustapi_rs::prelude::*;
226    ///
227    /// #[rustapi_rs::get("/users")]
228    /// async fn list_users() -> Json<Vec<User>> {
229    ///     Json(vec![])
230    /// }
231    ///
232    /// #[rustapi_rs::main]
233    /// async fn main() -> Result<()> {
234    ///     RustApi::auto().run("0.0.0.0:8080").await
235    /// }
236    /// ```
237    #[cfg(feature = "swagger-ui")]
238    pub fn auto() -> Self {
239        Self::new().mount_auto_routes_grouped().docs("/docs")
240    }
241
242    #[cfg(not(feature = "swagger-ui"))]
243    pub fn auto() -> Self {
244        Self::new().mount_auto_routes_grouped()
245    }
246
247    /// Create a configurable RustAPI application with auto-routes.
248    ///
249    /// Provides builder methods for customization while still
250    /// auto-registering all decorated routes.
251    ///
252    /// # Example
253    ///
254    /// ```rust,ignore
255    /// use rustapi_rs::prelude::*;
256    ///
257    /// RustApi::config()
258    ///     .docs_path("/api-docs")
259    ///     .body_limit(5 * 1024 * 1024)  // 5MB
260    ///     .openapi_info("My API", "2.0.0", Some("API Description"))
261    ///     .run("0.0.0.0:8080")
262    ///     .await?;
263    /// ```
264    pub fn config() -> RustApiConfig {
265        RustApiConfig::new()
266    }
267
268    /// Set the global body size limit for request bodies
269    ///
270    /// This protects against denial-of-service attacks via large payloads.
271    /// The default limit is 1MB (1024 * 1024 bytes).
272    ///
273    /// # Arguments
274    ///
275    /// * `limit` - Maximum body size in bytes
276    ///
277    /// # Example
278    ///
279    /// ```rust,ignore
280    /// use rustapi_rs::prelude::*;
281    ///
282    /// RustApi::new()
283    ///     .body_limit(5 * 1024 * 1024)  // 5MB limit
284    ///     .route("/upload", post(upload_handler))
285    ///     .run("127.0.0.1:8080")
286    ///     .await
287    /// ```
288    pub fn body_limit(mut self, limit: usize) -> Self {
289        self.body_limit = Some(limit);
290        self
291    }
292
293    /// Disable the body size limit
294    ///
295    /// Warning: This removes protection against large payload attacks.
296    /// Only use this if you have other mechanisms to limit request sizes.
297    ///
298    /// # Example
299    ///
300    /// ```rust,ignore
301    /// RustApi::new()
302    ///     .no_body_limit()  // Disable body size limit
303    ///     .route("/upload", post(upload_handler))
304    /// ```
305    pub fn no_body_limit(mut self) -> Self {
306        self.body_limit = None;
307        self
308    }
309
310    /// Add a middleware layer to the application
311    ///
312    /// Layers are executed in the order they are added (outermost first).
313    /// The first layer added will be the first to process the request and
314    /// the last to process the response.
315    ///
316    /// # Example
317    ///
318    /// ```rust,ignore
319    /// use rustapi_rs::prelude::*;
320    /// use rustapi_core::middleware::{RequestIdLayer, TracingLayer};
321    ///
322    /// RustApi::new()
323    ///     .layer(RequestIdLayer::new())  // First to process request
324    ///     .layer(TracingLayer::new())    // Second to process request
325    ///     .route("/", get(handler))
326    ///     .run("127.0.0.1:8080")
327    ///     .await
328    /// ```
329    pub fn layer<L>(mut self, layer: L) -> Self
330    where
331        L: MiddlewareLayer,
332    {
333        self.layers.push(Box::new(layer));
334        self
335    }
336
337    /// Add a request interceptor to the application
338    ///
339    /// Request interceptors are executed in registration order before the route handler.
340    /// Each interceptor can modify the request before passing it to the next interceptor
341    /// or handler.
342    ///
343    /// # Example
344    ///
345    /// ```rust,ignore
346    /// use rustapi_core::{RustApi, interceptor::RequestInterceptor, Request};
347    ///
348    /// #[derive(Clone)]
349    /// struct AddRequestId;
350    ///
351    /// impl RequestInterceptor for AddRequestId {
352    ///     fn intercept(&self, mut req: Request) -> Request {
353    ///         req.extensions_mut().insert(uuid::Uuid::new_v4());
354    ///         req
355    ///     }
356    ///
357    ///     fn clone_box(&self) -> Box<dyn RequestInterceptor> {
358    ///         Box::new(self.clone())
359    ///     }
360    /// }
361    ///
362    /// RustApi::new()
363    ///     .request_interceptor(AddRequestId)
364    ///     .route("/", get(handler))
365    ///     .run("127.0.0.1:8080")
366    ///     .await
367    /// ```
368    pub fn request_interceptor<I>(mut self, interceptor: I) -> Self
369    where
370        I: RequestInterceptor,
371    {
372        self.interceptors.add_request_interceptor(interceptor);
373        self
374    }
375
376    /// Add a response interceptor to the application
377    ///
378    /// Response interceptors are executed in reverse registration order after the route
379    /// handler completes. Each interceptor can modify the response before passing it
380    /// to the previous interceptor or client.
381    ///
382    /// # Example
383    ///
384    /// ```rust,ignore
385    /// use rustapi_core::{RustApi, interceptor::ResponseInterceptor, Response};
386    ///
387    /// #[derive(Clone)]
388    /// struct AddServerHeader;
389    ///
390    /// impl ResponseInterceptor for AddServerHeader {
391    ///     fn intercept(&self, mut res: Response) -> Response {
392    ///         res.headers_mut().insert("X-Server", "RustAPI".parse().unwrap());
393    ///         res
394    ///     }
395    ///
396    ///     fn clone_box(&self) -> Box<dyn ResponseInterceptor> {
397    ///         Box::new(self.clone())
398    ///     }
399    /// }
400    ///
401    /// RustApi::new()
402    ///     .response_interceptor(AddServerHeader)
403    ///     .route("/", get(handler))
404    ///     .run("127.0.0.1:8080")
405    ///     .await
406    /// ```
407    pub fn response_interceptor<I>(mut self, interceptor: I) -> Self
408    where
409        I: ResponseInterceptor,
410    {
411        self.interceptors.add_response_interceptor(interceptor);
412        self
413    }
414
415    /// Add application state
416    ///
417    /// State is shared across all handlers and can be extracted using `State<T>`.
418    ///
419    /// # Example
420    ///
421    /// ```rust,ignore
422    /// #[derive(Clone)]
423    /// struct AppState {
424    ///     db: DbPool,
425    /// }
426    ///
427    /// RustApi::new()
428    ///     .state(AppState::new())
429    /// ```
430    pub fn state<S>(self, _state: S) -> Self
431    where
432        S: Clone + Send + Sync + 'static,
433    {
434        // Store state in the router's shared Extensions so `State<T>` extractor can retrieve it.
435        let state = _state;
436        let mut app = self;
437        let r = std::mem::take(&mut app.router);
438        app.router = r.state(state);
439        app
440    }
441
442    /// Register an `on_start` lifecycle hook
443    ///
444    /// The callback runs **after** route registration and **before** the server
445    /// begins accepting connections. Multiple hooks execute in registration order.
446    ///
447    /// # Example
448    ///
449    /// ```rust,ignore
450    /// RustApi::new()
451    ///     .on_start(|| async {
452    ///         println!("🚀 Server starting...");
453    ///         // e.g. run DB migrations, warm caches
454    ///     })
455    ///     .run("127.0.0.1:8080")
456    ///     .await
457    /// ```
458    pub fn on_start<F, Fut>(mut self, hook: F) -> Self
459    where
460        F: FnOnce() -> Fut + Send + 'static,
461        Fut: Future<Output = ()> + Send + 'static,
462    {
463        self.lifecycle_hooks
464            .on_start
465            .push(Box::new(move || Box::pin(hook())));
466        self
467    }
468
469    /// Register an `on_shutdown` lifecycle hook
470    ///
471    /// The callback runs **after** the shutdown signal is received and the server
472    /// stops accepting new connections. Multiple hooks execute in registration order.
473    ///
474    /// # Example
475    ///
476    /// ```rust,ignore
477    /// RustApi::new()
478    ///     .on_shutdown(|| async {
479    ///         println!("👋 Server shutting down...");
480    ///         // e.g. flush logs, close DB connections
481    ///     })
482    ///     .run_with_shutdown("127.0.0.1:8080", ctrl_c())
483    ///     .await
484    /// ```
485    pub fn on_shutdown<F, Fut>(mut self, hook: F) -> Self
486    where
487        F: FnOnce() -> Fut + Send + 'static,
488        Fut: Future<Output = ()> + Send + 'static,
489    {
490        self.lifecycle_hooks
491            .on_shutdown
492            .push(Box::new(move || Box::pin(hook())));
493        self
494    }
495
496    /// Enable hot-reload mode for development
497    ///
498    /// When enabled:
499    /// - A dev-mode banner is printed at startup
500    /// - The `RUSTAPI_HOT_RELOAD` env var is set so that `cargo rustapi watch`
501    ///   can detect the server is reload-aware
502    /// - If the server is **not** already running under the CLI watcher,
503    ///   a helpful hint is printed suggesting `cargo rustapi run --watch`
504    ///
505    /// # Example
506    ///
507    /// ```rust,ignore
508    /// RustApi::new()
509    ///     .hot_reload(true)
510    ///     .route("/", get(hello))
511    ///     .run("127.0.0.1:8080")
512    ///     .await
513    /// ```
514    pub fn hot_reload(mut self, enabled: bool) -> Self {
515        self.hot_reload = enabled;
516        self
517    }
518
519    /// Register an OpenAPI schema
520    ///
521    /// # Example
522    ///
523    /// ```rust,ignore
524    /// #[derive(Schema)]
525    /// struct User { ... }
526    ///
527    /// RustApi::new()
528    ///     .register_schema::<User>()
529    /// ```
530    pub fn register_schema<T: rustapi_openapi::schema::RustApiSchema>(mut self) -> Self {
531        self.openapi_spec = self.openapi_spec.register::<T>();
532        self
533    }
534
535    /// Configure OpenAPI info (title, version, description)
536    pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
537        // NOTE: Do not reset the spec here; doing so would drop collected paths/schemas.
538        // This is especially important for `RustApi::auto()` and `RustApi::config()`.
539        self.openapi_spec.info.title = title.to_string();
540        self.openapi_spec.info.version = version.to_string();
541        self.openapi_spec.info.description = description.map(|d| d.to_string());
542        self
543    }
544
545    /// Get the current OpenAPI spec (for advanced usage/testing).
546    pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
547        &self.openapi_spec
548    }
549
550    /// If RUSTAPI_DUMP_OPENAPI=1 (or true), print the generated OpenAPI spec as JSON
551    /// to stdout and exit immediately. Used by `cargo rustapi mcp generate` to
552    /// extract the spec without needing a running HTTP server.
553    fn maybe_dump_openapi(&self) {
554        if let Ok(val) = std::env::var("RUSTAPI_DUMP_OPENAPI") {
555            if matches!(val.as_str(), "1" | "true" | "yes") {
556                let json = self.openapi_spec.to_json();
557                // Print clean JSON only
558                if let Ok(pretty) = serde_json::to_string_pretty(&json) {
559                    println!("{}", pretty);
560                } else {
561                    println!("{}", json);
562                }
563                std::process::exit(0);
564            }
565        }
566    }
567
568    fn mount_auto_routes_grouped(mut self) -> Self {
569        let routes = crate::auto_route::collect_auto_routes();
570
571        if routes.is_empty() {
572            // This is a common source of confusion with linkme-based auto registration.
573            // We emit a clear warning so users know their annotated handlers were not linked.
574            tracing::warn!(
575                target: "rustapi::auto",
576                count = 0,
577                "RustApi::auto() collected 0 routes. \
578                 This usually means either:\n\
579                 - No handlers were annotated with #[rustapi_rs::get], #[post], etc.\n\
580                 - The binary/test was not linked with the annotated modules (common in some test setups).\n\
581                 - You are building a library (cdylib/rlib) where linkme distributed slices may not be populated.\n\n\
582                 You can still register routes manually with .route() or check with rustapi_rs::auto_route_count()."
583            );
584        } else {
585            #[cfg(feature = "tracing")]
586            tracing::debug!(
587                target: "rustapi::auto",
588                count = routes.len(),
589                "Auto route collection found handlers"
590            );
591        }
592
593        // Use BTreeMap for deterministic route registration order
594        let mut by_path: BTreeMap<String, MethodRouter> = BTreeMap::new();
595
596        for route in routes {
597            let crate::handler::Route {
598                path: route_path,
599                method,
600                handler,
601                operation,
602                component_registrar,
603                ..
604            } = route;
605
606            let method_enum = match method {
607                "GET" => http::Method::GET,
608                "POST" => http::Method::POST,
609                "PUT" => http::Method::PUT,
610                "DELETE" => http::Method::DELETE,
611                "PATCH" => http::Method::PATCH,
612                _ => http::Method::GET,
613            };
614
615            let path = if route_path.starts_with('/') {
616                route_path.to_string()
617            } else {
618                format!("/{}", route_path)
619            };
620
621            let entry = by_path.entry(path).or_default();
622            entry.insert_boxed_with_operation(method_enum, handler, operation, component_registrar);
623        }
624
625        #[cfg(feature = "tracing")]
626        let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
627        #[cfg(feature = "tracing")]
628        let path_count = by_path.len();
629
630        for (path, method_router) in by_path {
631            self = self.route(&path, method_router);
632        }
633
634        crate::trace_info!(
635            paths = path_count,
636            routes = route_count,
637            "Auto-registered routes"
638        );
639
640        // Apply any auto-registered schemas.
641        crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
642
643        self
644    }
645
646    /// Add a route
647    ///
648    /// # Example
649    ///
650    /// ```rust,ignore
651    /// RustApi::new()
652    ///     .route("/", get(index))
653    ///     .route("/users", get(list_users).post(create_user))
654    ///     .route("/users/{id}", get(get_user).delete(delete_user))
655    /// ```
656    pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
657        for register_components in &method_router.component_registrars {
658            register_components(&mut self.openapi_spec);
659        }
660
661        // Register operations in OpenAPI spec
662        for (method, op) in &method_router.operations {
663            let mut op = op.clone();
664            add_path_params_to_operation(path, &mut op, &BTreeMap::new());
665            self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
666        }
667
668        self.router = self.router.route(path, method_router);
669        self
670    }
671
672    /// Add a typed route
673    pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
674        self.route(P::PATH, method_router)
675    }
676
677    /// Mount a handler (convenience method)
678    ///
679    /// Alias for `.route(path, method_router)` for a single handler.
680    #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
681    pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
682        self.route(path, method_router)
683    }
684
685    /// Mount a route created with `#[rustapi::get]`, `#[rustapi::post]`, etc.
686    ///
687    /// # Example
688    ///
689    /// ```rust,ignore
690    /// use rustapi_rs::prelude::*;
691    ///
692    /// #[rustapi::get("/users")]
693    /// async fn list_users() -> Json<Vec<User>> {
694    ///     Json(vec![])
695    /// }
696    ///
697    /// RustApi::new()
698    ///     .mount_route(route!(list_users))
699    ///     .run("127.0.0.1:8080")
700    ///     .await
701    /// ```
702    pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
703        let method_enum = match route.method {
704            "GET" => http::Method::GET,
705            "POST" => http::Method::POST,
706            "PUT" => http::Method::PUT,
707            "DELETE" => http::Method::DELETE,
708            "PATCH" => http::Method::PATCH,
709            _ => http::Method::GET,
710        };
711
712        (route.component_registrar)(&mut self.openapi_spec);
713
714        // Register operation in OpenAPI spec
715        let mut op = route.operation;
716        add_path_params_to_operation(route.path, &mut op, &route.param_schemas);
717        self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
718
719        self.route_with_method(route.path, method_enum, route.handler)
720    }
721
722    /// Helper to mount a single method handler
723    fn route_with_method(
724        self,
725        path: &str,
726        method: http::Method,
727        handler: crate::handler::BoxedHandler,
728    ) -> Self {
729        use crate::router::MethodRouter;
730        // use http::Method; // Removed
731
732        // This is simplified. In a real implementation we'd merge with existing router at this path
733        // For now we assume one handler per path or we simply allow overwriting for this MVP step
734        // (matchit router doesn't allow easy merging/updating existing entries without rebuilding)
735        //
736        // TOOD: Enhance Router to support method merging
737
738        let path = if !path.starts_with('/') {
739            format!("/{}", path)
740        } else {
741            path.to_string()
742        };
743
744        // Check if we already have this path?
745        // For MVP, valid assumption: user calls .route() or .mount() once per path-method-combo
746        // But we need to handle multiple methods on same path.
747        // Our Router wrapper currently just inserts.
748
749        // Since we can't easily query matchit, we'll just insert.
750        // Limitations: strictly sequential mounting for now.
751
752        let mut handlers = std::collections::HashMap::new();
753        handlers.insert(method, handler);
754
755        let method_router = MethodRouter::from_boxed(handlers);
756        self.route(&path, method_router)
757    }
758
759    /// Nest a router under a prefix
760    ///
761    /// All routes from the nested router will be registered with the prefix
762    /// prepended to their paths. OpenAPI operations from the nested router
763    /// are also propagated to the parent's OpenAPI spec with prefixed paths.
764    ///
765    /// # Example
766    ///
767    /// ```rust,ignore
768    /// let api_v1 = Router::new()
769    ///     .route("/users", get(list_users));
770    ///
771    /// RustApi::new()
772    ///     .nest("/api/v1", api_v1)
773    /// ```
774    pub fn nest(mut self, prefix: &str, router: Router) -> Self {
775        // Normalize the prefix for OpenAPI paths
776        let normalized_prefix = normalize_prefix_for_openapi(prefix);
777
778        // Propagate OpenAPI operations from nested router with prefixed paths
779        // We need to do this before calling router.nest() because it consumes the router
780        for (matchit_path, method_router) in router.method_routers() {
781            for register_components in &method_router.component_registrars {
782                register_components(&mut self.openapi_spec);
783            }
784
785            // Get the display path from registered_routes (has {param} format)
786            let display_path = router
787                .registered_routes()
788                .get(matchit_path)
789                .map(|info| info.path.clone())
790                .unwrap_or_else(|| matchit_path.clone());
791
792            // Build the prefixed display path for OpenAPI
793            let prefixed_path = if display_path == "/" {
794                normalized_prefix.clone()
795            } else {
796                format!("{}{}", normalized_prefix, display_path)
797            };
798
799            // Register each operation in the OpenAPI spec
800            for (method, op) in &method_router.operations {
801                let mut op = op.clone();
802                add_path_params_to_operation(&prefixed_path, &mut op, &BTreeMap::new());
803                self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
804            }
805        }
806
807        // Delegate to Router::nest for actual route registration
808        self.router = self.router.nest(prefix, router);
809        self
810    }
811
812    /// Serve static files from a directory
813    ///
814    /// Maps a URL path prefix to a filesystem directory. Requests to paths under
815    /// the prefix will serve files from the corresponding location in the directory.
816    ///
817    /// # Arguments
818    ///
819    /// * `prefix` - URL path prefix (e.g., "/static", "/assets")
820    /// * `root` - Filesystem directory path
821    ///
822    /// # Features
823    ///
824    /// - Automatic MIME type detection
825    /// - ETag and Last-Modified headers for caching
826    /// - Index file serving for directories
827    /// - Path traversal prevention
828    ///
829    /// # Example
830    ///
831    /// ```rust,ignore
832    /// use rustapi_rs::prelude::*;
833    ///
834    /// RustApi::new()
835    ///     .serve_static("/assets", "./public")
836    ///     .serve_static("/uploads", "./uploads")
837    ///     .run("127.0.0.1:8080")
838    ///     .await
839    /// ```
840    pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
841        self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
842    }
843
844    /// Serve static files with custom configuration
845    ///
846    /// # Example
847    ///
848    /// ```rust,ignore
849    /// use rustapi_core::static_files::StaticFileConfig;
850    ///
851    /// let config = StaticFileConfig::new("./public", "/assets")
852    ///     .max_age(86400)  // Cache for 1 day
853    ///     .fallback("index.html");  // SPA fallback
854    ///
855    /// RustApi::new()
856    ///     .serve_static_with_config(config)
857    ///     .run("127.0.0.1:8080")
858    ///     .await
859    /// ```
860    pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
861        use crate::router::MethodRouter;
862        use std::collections::HashMap;
863
864        let prefix = config.prefix.clone();
865        let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
866
867        // Create the static file handler
868        let handler: crate::handler::BoxedHandler =
869            std::sync::Arc::new(move |req: crate::Request| {
870                let config = config.clone();
871                let path = req.uri().path().to_string();
872
873                Box::pin(async move {
874                    let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
875
876                    match crate::static_files::StaticFile::serve(relative_path, &config).await {
877                        Ok(response) => response,
878                        Err(err) => err.into_response(),
879                    }
880                })
881                    as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
882            });
883
884        let mut handlers = HashMap::new();
885        handlers.insert(http::Method::GET, handler);
886        let method_router = MethodRouter::from_boxed(handlers);
887
888        self.route(&catch_all_path, method_router)
889    }
890
891    /// Enable response compression
892    ///
893    /// Adds gzip/deflate compression for response bodies. The compression
894    /// is based on the client's Accept-Encoding header.
895    ///
896    /// # Example
897    ///
898    /// ```rust,ignore
899    /// use rustapi_rs::prelude::*;
900    ///
901    /// RustApi::new()
902    ///     .compression()
903    ///     .route("/", get(handler))
904    ///     .run("127.0.0.1:8080")
905    ///     .await
906    /// ```
907    #[cfg(feature = "compression")]
908    pub fn compression(self) -> Self {
909        self.layer(crate::middleware::CompressionLayer::new())
910    }
911
912    /// Enable response compression with custom configuration
913    ///
914    /// # Example
915    ///
916    /// ```rust,ignore
917    /// use rustapi_core::middleware::CompressionConfig;
918    ///
919    /// RustApi::new()
920    ///     .compression_with_config(
921    ///         CompressionConfig::new()
922    ///             .min_size(512)
923    ///             .level(9)
924    ///     )
925    ///     .route("/", get(handler))
926    /// ```
927    #[cfg(feature = "compression")]
928    pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
929        self.layer(crate::middleware::CompressionLayer::with_config(config))
930    }
931
932    /// Enable Swagger UI documentation
933    ///
934    /// This adds two endpoints:
935    /// - `{path}` - Swagger UI interface
936    /// - `{path}/openapi.json` - OpenAPI JSON specification
937    ///
938    /// **Important:** Call `.docs()` AFTER registering all routes. The OpenAPI
939    /// specification is captured at the time `.docs()` is called, so routes
940    /// added afterwards will not appear in the documentation.
941    ///
942    /// # Example
943    ///
944    /// ```text
945    /// RustApi::new()
946    ///     .route("/users", get(list_users))     // Add routes first
947    ///     .route("/posts", get(list_posts))     // Add more routes
948    ///     .docs("/docs")  // Then enable docs - captures all routes above
949    ///     .run("127.0.0.1:8080")
950    ///     .await
951    /// ```
952    ///
953    /// For `RustApi::auto()`, routes are collected before `.docs()` is called,
954    /// so this is handled automatically.
955    #[cfg(feature = "swagger-ui")]
956    pub fn docs(self, path: &str) -> Self {
957        let title = self.openapi_spec.info.title.clone();
958        let version = self.openapi_spec.info.version.clone();
959        let description = self.openapi_spec.info.description.clone();
960
961        self.docs_with_info(path, &title, &version, description.as_deref())
962    }
963
964    /// Enable Swagger UI documentation with custom API info
965    ///
966    /// # Example
967    ///
968    /// ```rust,ignore
969    /// RustApi::new()
970    ///     .docs_with_info("/docs", "My API", "2.0.0", Some("API for managing users"))
971    /// ```
972    #[cfg(feature = "swagger-ui")]
973    pub fn docs_with_info(
974        mut self,
975        path: &str,
976        title: &str,
977        version: &str,
978        description: Option<&str>,
979    ) -> Self {
980        use crate::router::get;
981        // Update spec info
982        self.openapi_spec.info.title = title.to_string();
983        self.openapi_spec.info.version = version.to_string();
984        if let Some(desc) = description {
985            self.openapi_spec.info.description = Some(desc.to_string());
986        }
987
988        let path = path.trim_end_matches('/');
989        let openapi_path = format!("{}/openapi.json", path);
990
991        // Clone values for closures
992        let spec_value = self.openapi_spec.to_json();
993        let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
994            // Safe fallback if JSON serialization fails (though unlikely for Value)
995            tracing::error!("Failed to serialize OpenAPI spec: {}", e);
996            "{}".to_string()
997        });
998        let openapi_url = openapi_path.clone();
999
1000        // Add OpenAPI JSON endpoint
1001        let spec_handler = move || {
1002            let json = spec_json.clone();
1003            async move {
1004                http::Response::builder()
1005                    .status(http::StatusCode::OK)
1006                    .header(http::header::CONTENT_TYPE, "application/json")
1007                    .body(crate::response::Body::from(json))
1008                    .unwrap_or_else(|e| {
1009                        tracing::error!("Failed to build response: {}", e);
1010                        http::Response::builder()
1011                            .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1012                            .body(crate::response::Body::from("Internal Server Error"))
1013                            .unwrap()
1014                    })
1015            }
1016        };
1017
1018        // Add Swagger UI endpoint
1019        let docs_handler = move || {
1020            let url = openapi_url.clone();
1021            async move {
1022                let response = rustapi_openapi::swagger_ui_html(&url);
1023                response.map(crate::response::Body::Full)
1024            }
1025        };
1026
1027        self.route(&openapi_path, get(spec_handler))
1028            .route(path, get(docs_handler))
1029    }
1030
1031    /// Enable Swagger UI documentation with Basic Auth protection
1032    ///
1033    /// When username and password are provided, the docs endpoint will require
1034    /// Basic Authentication. This is useful for protecting API documentation
1035    /// in production environments.
1036    ///
1037    /// # Example
1038    ///
1039    /// ```rust,ignore
1040    /// RustApi::new()
1041    ///     .route("/users", get(list_users))
1042    ///     .docs_with_auth("/docs", "admin", "secret123")
1043    ///     .run("127.0.0.1:8080")
1044    ///     .await
1045    /// ```
1046    #[cfg(feature = "swagger-ui")]
1047    pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
1048        let title = self.openapi_spec.info.title.clone();
1049        let version = self.openapi_spec.info.version.clone();
1050        let description = self.openapi_spec.info.description.clone();
1051
1052        self.docs_with_auth_and_info(
1053            path,
1054            username,
1055            password,
1056            &title,
1057            &version,
1058            description.as_deref(),
1059        )
1060    }
1061
1062    /// Enable Swagger UI documentation with Basic Auth and custom API info
1063    ///
1064    /// # Example
1065    ///
1066    /// ```rust,ignore
1067    /// RustApi::new()
1068    ///     .docs_with_auth_and_info(
1069    ///         "/docs",
1070    ///         "admin",
1071    ///         "secret",
1072    ///         "My API",
1073    ///         "2.0.0",
1074    ///         Some("Protected API documentation")
1075    ///     )
1076    /// ```
1077    #[cfg(feature = "swagger-ui")]
1078    pub fn docs_with_auth_and_info(
1079        mut self,
1080        path: &str,
1081        username: &str,
1082        password: &str,
1083        title: &str,
1084        version: &str,
1085        description: Option<&str>,
1086    ) -> Self {
1087        use crate::router::MethodRouter;
1088        use std::collections::HashMap;
1089
1090        #[inline]
1091        fn base64_encode(input: &[u8]) -> String {
1092            const ALPHA: &[u8; 64] =
1093                b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1094            let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1095            for chunk in input.chunks(3) {
1096                let b0 = chunk[0] as usize;
1097                let b1 = if chunk.len() > 1 {
1098                    chunk[1] as usize
1099                } else {
1100                    0
1101                };
1102                let b2 = if chunk.len() > 2 {
1103                    chunk[2] as usize
1104                } else {
1105                    0
1106                };
1107                out.push(ALPHA[b0 >> 2] as char);
1108                out.push(ALPHA[((b0 & 3) << 4) | (b1 >> 4)] as char);
1109                out.push(if chunk.len() > 1 {
1110                    ALPHA[((b1 & 0xf) << 2) | (b2 >> 6)] as char
1111                } else {
1112                    '='
1113                });
1114                out.push(if chunk.len() > 2 {
1115                    ALPHA[b2 & 63] as char
1116                } else {
1117                    '='
1118                });
1119            }
1120            out
1121        }
1122
1123        // Update spec info
1124        self.openapi_spec.info.title = title.to_string();
1125        self.openapi_spec.info.version = version.to_string();
1126        if let Some(desc) = description {
1127            self.openapi_spec.info.description = Some(desc.to_string());
1128        }
1129
1130        let path = path.trim_end_matches('/');
1131        let openapi_path = format!("{}/openapi.json", path);
1132
1133        // Create expected auth header value
1134        let credentials = format!("{}:{}", username, password);
1135        let encoded = base64_encode(credentials.as_bytes());
1136        let expected_auth = format!("Basic {}", encoded);
1137
1138        // Clone values for closures
1139        let spec_value = self.openapi_spec.to_json();
1140        let spec_json = serde_json::to_string_pretty(&spec_value).unwrap_or_else(|e| {
1141            tracing::error!("Failed to serialize OpenAPI spec: {}", e);
1142            "{}".to_string()
1143        });
1144        let openapi_url = openapi_path.clone();
1145        let expected_auth_spec = expected_auth.clone();
1146        let expected_auth_docs = expected_auth;
1147
1148        // Create spec handler with auth check
1149        let spec_handler: crate::handler::BoxedHandler =
1150            std::sync::Arc::new(move |req: crate::Request| {
1151                let json = spec_json.clone();
1152                let expected = expected_auth_spec.clone();
1153                Box::pin(async move {
1154                    if !check_basic_auth(&req, &expected) {
1155                        return unauthorized_response();
1156                    }
1157                    http::Response::builder()
1158                        .status(http::StatusCode::OK)
1159                        .header(http::header::CONTENT_TYPE, "application/json")
1160                        .body(crate::response::Body::from(json))
1161                        .unwrap_or_else(|e| {
1162                            tracing::error!("Failed to build response: {}", e);
1163                            http::Response::builder()
1164                                .status(http::StatusCode::INTERNAL_SERVER_ERROR)
1165                                .body(crate::response::Body::from("Internal Server Error"))
1166                                .unwrap()
1167                        })
1168                })
1169                    as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1170            });
1171
1172        // Create docs handler with auth check
1173        let docs_handler: crate::handler::BoxedHandler =
1174            std::sync::Arc::new(move |req: crate::Request| {
1175                let url = openapi_url.clone();
1176                let expected = expected_auth_docs.clone();
1177                Box::pin(async move {
1178                    if !check_basic_auth(&req, &expected) {
1179                        return unauthorized_response();
1180                    }
1181                    let response = rustapi_openapi::swagger_ui_html(&url);
1182                    response.map(crate::response::Body::Full)
1183                })
1184                    as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
1185            });
1186
1187        // Create method routers with boxed handlers
1188        let mut spec_handlers = HashMap::new();
1189        spec_handlers.insert(http::Method::GET, spec_handler);
1190        let spec_router = MethodRouter::from_boxed(spec_handlers);
1191
1192        let mut docs_handlers = HashMap::new();
1193        docs_handlers.insert(http::Method::GET, docs_handler);
1194        let docs_router = MethodRouter::from_boxed(docs_handlers);
1195
1196        self.route(&openapi_path, spec_router)
1197            .route(path, docs_router)
1198    }
1199
1200    /// Enable automatic status page with default configuration
1201    pub fn status_page(self) -> Self {
1202        self.status_page_with_config(crate::status::StatusConfig::default())
1203    }
1204
1205    /// Enable automatic status page with custom configuration
1206    pub fn status_page_with_config(mut self, config: crate::status::StatusConfig) -> Self {
1207        self.status_config = Some(config);
1208        self
1209    }
1210
1211    /// Enable built-in `/health`, `/ready`, and `/live` endpoints with default paths.
1212    ///
1213    /// The default health check includes a lightweight `self` probe so the
1214    /// endpoints are immediately useful even before dependency checks are added.
1215    pub fn health_endpoints(mut self) -> Self {
1216        self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1217        if self.health_check.is_none() {
1218            self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1219        }
1220        self
1221    }
1222
1223    /// Enable built-in health endpoints with custom paths.
1224    pub fn health_endpoints_with_config(
1225        mut self,
1226        config: crate::health::HealthEndpointConfig,
1227    ) -> Self {
1228        self.health_endpoint_config = Some(config);
1229        if self.health_check.is_none() {
1230            self.health_check = Some(crate::health::HealthCheckBuilder::default().build());
1231        }
1232        self
1233    }
1234
1235    /// Register a custom health check and enable built-in health endpoints.
1236    ///
1237    /// The configured check is used by `/health` and `/ready`, while `/live`
1238    /// remains a lightweight process-level probe.
1239    pub fn with_health_check(mut self, health_check: crate::health::HealthCheck) -> Self {
1240        self.health_check = Some(health_check);
1241        if self.health_endpoint_config.is_none() {
1242            self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default());
1243        }
1244        self
1245    }
1246
1247    /// Apply a one-call production baseline preset.
1248    ///
1249    /// This enables:
1250    /// - `RequestIdLayer`
1251    /// - `TracingLayer` with `service` and `environment` fields
1252    /// - built-in `/health`, `/ready`, and `/live` probes
1253    pub fn production_defaults(self, service_name: impl Into<String>) -> Self {
1254        self.production_defaults_with_config(ProductionDefaultsConfig::new(service_name))
1255    }
1256
1257    /// Apply the production baseline preset with custom configuration.
1258    pub fn production_defaults_with_config(mut self, config: ProductionDefaultsConfig) -> Self {
1259        if config.enable_request_id {
1260            self = self.layer(crate::middleware::RequestIdLayer::new());
1261        }
1262
1263        if config.enable_tracing {
1264            let mut tracing_layer =
1265                crate::middleware::TracingLayer::with_level(config.tracing_level)
1266                    .with_field("service", config.service_name.clone())
1267                    .with_field("environment", crate::error::get_environment().to_string());
1268
1269            if let Some(version) = &config.version {
1270                tracing_layer = tracing_layer.with_field("version", version.clone());
1271            }
1272
1273            self = self.layer(tracing_layer);
1274        }
1275
1276        if config.enable_health_endpoints {
1277            if self.health_check.is_none() {
1278                let mut builder = crate::health::HealthCheckBuilder::default();
1279                if let Some(version) = &config.version {
1280                    builder = builder.version(version.clone());
1281                }
1282                self.health_check = Some(builder.build());
1283            }
1284
1285            if self.health_endpoint_config.is_none() {
1286                self.health_endpoint_config =
1287                    Some(config.health_endpoint_config.unwrap_or_default());
1288            }
1289        }
1290
1291        self
1292    }
1293
1294    /// Print a hot-reload dev banner if `.hot_reload(true)` is set
1295    fn print_hot_reload_banner(&self, addr: &str) {
1296        if !self.hot_reload {
1297            return;
1298        }
1299
1300        // Set the env var so the CLI watcher can detect it
1301        std::env::set_var("RUSTAPI_HOT_RELOAD", "1");
1302
1303        let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD")
1304            .map(|v| v == "1")
1305            .unwrap_or(false);
1306
1307        tracing::info!("🔄 Hot-reload mode enabled");
1308
1309        if is_under_watcher {
1310            tracing::info!("   File watcher active — changes will trigger rebuild + restart");
1311        } else {
1312            tracing::info!("   Tip: Run with `cargo rustapi run --watch` for automatic hot-reload");
1313        }
1314
1315        tracing::info!("   Listening on http://{addr}");
1316    }
1317
1318    // Helper to apply status page logic (monitor, layer, route)
1319    fn apply_health_endpoints(&mut self) {
1320        if let Some(config) = &self.health_endpoint_config {
1321            use crate::router::get;
1322
1323            let health_check = self
1324                .health_check
1325                .clone()
1326                .unwrap_or_else(|| crate::health::HealthCheckBuilder::default().build());
1327
1328            let health_path = config.health_path.clone();
1329            let readiness_path = config.readiness_path.clone();
1330            let liveness_path = config.liveness_path.clone();
1331
1332            let health_handler = {
1333                let health_check = health_check.clone();
1334                move || {
1335                    let health_check = health_check.clone();
1336                    async move { crate::health::health_response(health_check).await }
1337                }
1338            };
1339
1340            let readiness_handler = {
1341                let health_check = health_check.clone();
1342                move || {
1343                    let health_check = health_check.clone();
1344                    async move { crate::health::readiness_response(health_check).await }
1345                }
1346            };
1347
1348            let liveness_handler = || async { crate::health::liveness_response().await };
1349
1350            let router = std::mem::take(&mut self.router);
1351            self.router = router
1352                .route(&health_path, get(health_handler))
1353                .route(&readiness_path, get(readiness_handler))
1354                .route(&liveness_path, get(liveness_handler));
1355        }
1356    }
1357
1358    fn apply_status_page(&mut self) {
1359        if let Some(config) = &self.status_config {
1360            let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new());
1361
1362            // 1. Add middleware layer
1363            self.layers
1364                .push(Box::new(crate::status::StatusLayer::new(monitor.clone())));
1365
1366            // 2. Add status route
1367            use crate::router::MethodRouter;
1368            use std::collections::HashMap;
1369
1370            let monitor = monitor.clone();
1371            let config = config.clone();
1372            let path = config.path.clone(); // Clone path before moving config
1373
1374            let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| {
1375                let monitor = monitor.clone();
1376                let config = config.clone();
1377                Box::pin(async move {
1378                    crate::status::status_handler(monitor, config)
1379                        .await
1380                        .into_response()
1381                })
1382            });
1383
1384            let mut handlers = HashMap::new();
1385            handlers.insert(http::Method::GET, handler);
1386            let method_router = MethodRouter::from_boxed(handlers);
1387
1388            // We need to take the router out to call route() which consumes it
1389            let router = std::mem::take(&mut self.router);
1390            self.router = router.route(&path, method_router);
1391        }
1392    }
1393
1394    #[cfg(feature = "dashboard")]
1395    fn apply_dashboard(&mut self) {
1396        use crate::dashboard::{DashboardMetrics, RouteInventoryItem};
1397        use crate::handler::BoxedHandler;
1398        use crate::response::Body;
1399        use crate::router::MethodRouter;
1400        use std::collections::HashMap;
1401        use std::sync::Arc;
1402
1403        let mut config = match self.dashboard_config.take() {
1404            Some(c) => c,
1405            None => return,
1406        };
1407        config.normalize_paths();
1408
1409        // Build route inventory from currently registered routes. This snapshot
1410        // intentionally happens before dashboard routes are mounted so the UI
1411        // represents application endpoints rather than the dashboard itself.
1412        let mut inventory: Vec<RouteInventoryItem> = self
1413            .router
1414            .registered_routes()
1415            .values()
1416            .map(|info| {
1417                let methods: Vec<String> = info.methods.iter().map(|m| m.to_string()).collect();
1418                let health_eligible = self
1419                    .health_endpoint_config
1420                    .as_ref()
1421                    .map(|health| {
1422                        info.path == health.health_path
1423                            || info.path == health.readiness_path
1424                            || info.path == health.liveness_path
1425                    })
1426                    .unwrap_or(false);
1427
1428                RouteInventoryItem::new(info.path.clone(), methods)
1429                    .with_tags(openapi_tags_for_route(
1430                        &self.openapi_spec,
1431                        &info.path,
1432                        &info.methods,
1433                    ))
1434                    .with_feature_gates(infer_route_feature_gates(&info.path))
1435                    .health_eligible(health_eligible)
1436                    .replay_eligible(is_dashboard_replay_eligible(&info.path, health_eligible))
1437            })
1438            .collect();
1439        inventory.sort_by(|a, b| a.path.cmp(&b.path));
1440
1441        let metrics = Arc::new(DashboardMetrics::new_with_replay_admin_path(
1442            inventory,
1443            config.replay_api_path.clone(),
1444        ));
1445
1446        // Insert metrics into router state using the public .state() API
1447        let router = std::mem::take(&mut self.router);
1448        self.router = router.state(Arc::clone(&metrics));
1449
1450        // Register dashboard routes
1451        let prefix = config.path.trim_end_matches('/').to_owned();
1452
1453        fn not_found() -> crate::response::Response {
1454            http::Response::builder()
1455                .status(404)
1456                .body(Body::Full(http_body_util::Full::new(bytes::Bytes::from(
1457                    "Not Found",
1458                ))))
1459                .unwrap()
1460        }
1461
1462        // Route 1: GET /__rustapi/dashboard  (the SPA page)
1463        {
1464            let metrics_c = Arc::clone(&metrics);
1465            let config_c = config.clone();
1466            let handler: BoxedHandler = Arc::new(move |req| {
1467                let metrics = Arc::clone(&metrics_c);
1468                let cfg = config_c.clone();
1469                Box::pin(async move {
1470                    let headers = req.headers().clone();
1471                    let method = req.method().to_string();
1472                    let path = req.uri().path().to_owned();
1473                    crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg)
1474                        .await
1475                        .unwrap_or_else(not_found)
1476                })
1477            });
1478            let mut h = HashMap::new();
1479            h.insert(http::Method::GET, handler);
1480            let router = std::mem::take(&mut self.router);
1481            self.router = router.route(&prefix, MethodRouter::from_boxed(h));
1482        }
1483
1484        // Route 2: GET /__rustapi/dashboard/*path  (API sub-paths)
1485        {
1486            let metrics_c = Arc::clone(&metrics);
1487            let config_c = config.clone();
1488            let wildcard_path = format!("{}/*path", prefix);
1489            let handler: BoxedHandler = Arc::new(move |req| {
1490                let metrics = Arc::clone(&metrics_c);
1491                let cfg = config_c.clone();
1492                Box::pin(async move {
1493                    let headers = req.headers().clone();
1494                    let method = req.method().to_string();
1495                    let path = req.uri().path().to_owned();
1496                    crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg)
1497                        .await
1498                        .unwrap_or_else(not_found)
1499                })
1500            });
1501            let mut h = HashMap::new();
1502            h.insert(http::Method::GET, handler);
1503            let router = std::mem::take(&mut self.router);
1504            self.router = router.route(&wildcard_path, MethodRouter::from_boxed(h));
1505        }
1506    }
1507
1508    /// Enable the embedded isometric system dashboard.
1509    ///
1510    /// Registers a self-contained admin surface at the configured path
1511    /// (default: `/__rustapi/dashboard`).
1512    ///
1513    /// # Example
1514    ///
1515    /// ```rust,ignore
1516    /// use rustapi_core::dashboard::DashboardConfig;
1517    ///
1518    /// RustApi::new()
1519    ///     .route("/api/users", get(list_users))
1520    ///     .dashboard(
1521    ///         DashboardConfig::new()
1522    ///             .admin_token("my-secret")
1523    ///     )
1524    ///     .run("127.0.0.1:8080")
1525    ///     .await
1526    /// ```
1527    #[cfg(feature = "dashboard")]
1528    pub fn dashboard(mut self, config: crate::dashboard::DashboardConfig) -> Self {
1529        self.dashboard_config = Some(config);
1530        self
1531    }
1532
1533    /// Run the server
1534    ///
1535    /// # Example
1536    ///
1537    /// ```rust,ignore
1538    /// RustApi::new()
1539    ///     .route("/", get(hello))
1540    ///     .run("127.0.0.1:8080")
1541    ///     .await
1542    /// ```
1543    pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1544        self.maybe_dump_openapi();
1545
1546        // Hot-reload mode banner
1547        self.print_hot_reload_banner(addr);
1548
1549        // Apply health endpoints if configured
1550        self.apply_health_endpoints();
1551
1552        // Apply status page if configured
1553        self.apply_status_page();
1554
1555        // Apply embedded dashboard if configured
1556        #[cfg(feature = "dashboard")]
1557        self.apply_dashboard();
1558
1559        // Apply body limit layer if configured (should be first in the chain)
1560        if let Some(limit) = self.body_limit {
1561            // Prepend body limit layer so it's the first to process requests
1562            self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1563        }
1564
1565        // Run on_start lifecycle hooks before accepting connections
1566        for hook in self.lifecycle_hooks.on_start {
1567            hook().await;
1568        }
1569
1570        let server = Server::new(self.router, self.layers, self.interceptors);
1571        server.run(addr).await
1572    }
1573
1574    /// Run the server with graceful shutdown signal
1575    pub async fn run_with_shutdown<F>(
1576        mut self,
1577        addr: impl AsRef<str>,
1578        signal: F,
1579    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
1580    where
1581        F: std::future::Future<Output = ()> + Send + 'static,
1582    {
1583        self.maybe_dump_openapi();
1584
1585        // Hot-reload mode banner
1586        self.print_hot_reload_banner(addr.as_ref());
1587
1588        // Apply health endpoints if configured
1589        self.apply_health_endpoints();
1590
1591        // Apply status page if configured
1592        self.apply_status_page();
1593
1594        // Apply embedded dashboard if configured
1595        #[cfg(feature = "dashboard")]
1596        self.apply_dashboard();
1597
1598        if let Some(limit) = self.body_limit {
1599            self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1600        }
1601
1602        // Run on_start lifecycle hooks before accepting connections
1603        for hook in self.lifecycle_hooks.on_start {
1604            hook().await;
1605        }
1606
1607        // Wrap the shutdown signal to run on_shutdown hooks after signal fires
1608        let shutdown_hooks = self.lifecycle_hooks.on_shutdown;
1609        let wrapped_signal = async move {
1610            signal.await;
1611            // Run on_shutdown hooks after the shutdown signal fires
1612            for hook in shutdown_hooks {
1613                hook().await;
1614            }
1615        };
1616
1617        let server = Server::new(self.router, self.layers, self.interceptors);
1618        server
1619            .run_with_shutdown(addr.as_ref(), wrapped_signal)
1620            .await
1621    }
1622
1623    /// Get the inner router (for testing or advanced usage)
1624    pub fn into_router(self) -> Router {
1625        self.router
1626    }
1627
1628    /// Get a reference to the inner router (for advanced usage, e.g. in-process MCP dispatch).
1629    pub fn router(&self) -> &Router {
1630        &self.router
1631    }
1632
1633    /// Get the layer stack (for testing)
1634    pub fn layers(&self) -> &LayerStack {
1635        &self.layers
1636    }
1637
1638    /// Get the interceptor chain (for testing)
1639    pub fn interceptors(&self) -> &InterceptorChain {
1640        &self.interceptors
1641    }
1642
1643    /// Returns a dispatcher that can execute requests directly through this
1644    /// app's router + layers + interceptors, with zero network overhead.
1645    ///
1646    /// This is intended for in-process protocol integrations (e.g. MCP tool calls
1647    /// when running side-by-side with the main HTTP server).
1648    pub fn request_dispatcher(&self) -> RequestDispatcher {
1649        RequestDispatcher {
1650            router: Arc::new(self.router.clone()),
1651            layers: self.layers().clone(),
1652            interceptors: self.interceptors().clone(),
1653        }
1654    }
1655
1656    /// Enable HTTP/3 support with TLS certificates
1657    ///
1658    /// HTTP/3 requires TLS certificates. For development, you can use
1659    /// self-signed certificates with `run_http3_dev`.
1660    ///
1661    /// # Example
1662    ///
1663    /// ```rust,ignore
1664    /// RustApi::new()
1665    ///     .route("/", get(hello))
1666    ///     .run_http3("0.0.0.0:443", "cert.pem", "key.pem")
1667    ///     .await
1668    /// ```
1669    #[cfg(feature = "http3")]
1670    pub async fn run_http3(
1671        mut self,
1672        config: crate::http3::Http3Config,
1673    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1674        use std::sync::Arc;
1675
1676        // Apply health endpoints if configured
1677        self.apply_health_endpoints();
1678
1679        // Apply status page if configured
1680        self.apply_status_page();
1681
1682        // Apply body limit layer if configured
1683        if let Some(limit) = self.body_limit {
1684            self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1685        }
1686
1687        let server = crate::http3::Http3Server::new(
1688            &config,
1689            Arc::new(self.router.clone()),
1690            Arc::new(self.layers.clone()),
1691            Arc::new(self.interceptors.clone()),
1692        )
1693        .await?;
1694
1695        server.run().await
1696    }
1697
1698    /// Run HTTP/3 server with self-signed certificate (development only)
1699    ///
1700    /// This is useful for local development and testing.
1701    /// **Do not use in production!**
1702    ///
1703    /// # Example
1704    ///
1705    /// ```rust,ignore
1706    /// RustApi::new()
1707    ///     .route("/", get(hello))
1708    ///     .run_http3_dev("0.0.0.0:8443")
1709    ///     .await
1710    /// ```
1711    #[cfg(feature = "http3-dev")]
1712    pub async fn run_http3_dev(
1713        mut self,
1714        addr: &str,
1715    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1716        use std::sync::Arc;
1717
1718        // Apply health endpoints if configured
1719        self.apply_health_endpoints();
1720
1721        // Apply status page if configured
1722        self.apply_status_page();
1723
1724        // Apply body limit layer if configured
1725        if let Some(limit) = self.body_limit {
1726            self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1727        }
1728
1729        let server = crate::http3::Http3Server::new_with_self_signed(
1730            addr,
1731            Arc::new(self.router.clone()),
1732            Arc::new(self.layers.clone()),
1733            Arc::new(self.interceptors.clone()),
1734        )
1735        .await?;
1736
1737        server.run().await
1738    }
1739
1740    /// Configure HTTP/3 support for `run_http3` and `run_dual_stack`.
1741    ///
1742    /// # Example
1743    ///
1744    /// ```rust,ignore
1745    /// RustApi::new()
1746    ///     .with_http3("cert.pem", "key.pem")
1747    ///     .run_dual_stack("127.0.0.1:8080")
1748    ///     .await
1749    /// ```
1750    #[cfg(feature = "http3")]
1751    pub fn with_http3(mut self, cert_path: impl Into<String>, key_path: impl Into<String>) -> Self {
1752        self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path));
1753        self
1754    }
1755
1756    /// Run both HTTP/1.1 (TCP) and HTTP/3 (QUIC/UDP) simultaneously.
1757    ///
1758    /// The HTTP/3 listener is bound to the same host and port as `http_addr`
1759    /// so clients can upgrade to either protocol on one endpoint.
1760    ///
1761    /// # Example
1762    ///
1763    /// ```rust,ignore
1764    /// RustApi::new()
1765    ///     .route("/", get(hello))
1766    ///     .with_http3("cert.pem", "key.pem")
1767    ///     .run_dual_stack("0.0.0.0:8080")
1768    ///     .await
1769    /// ```
1770    #[cfg(feature = "http3")]
1771    pub async fn run_dual_stack(
1772        mut self,
1773        http_addr: &str,
1774    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1775        use std::sync::Arc;
1776
1777        let mut config = self
1778            .http3_config
1779            .take()
1780            .ok_or("HTTP/3 config not set. Use .with_http3(...)")?;
1781
1782        let http_socket: std::net::SocketAddr = http_addr.parse()?;
1783        config.bind_addr = if http_socket.ip().is_ipv6() {
1784            format!("[{}]", http_socket.ip())
1785        } else {
1786            http_socket.ip().to_string()
1787        };
1788        config.port = http_socket.port();
1789        let http_addr = http_socket.to_string();
1790
1791        // Apply health endpoints if configured
1792        self.apply_health_endpoints();
1793
1794        // Apply status page if configured
1795        self.apply_status_page();
1796
1797        // Apply body limit layer if configured
1798        if let Some(limit) = self.body_limit {
1799            self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
1800        }
1801
1802        let router = Arc::new(self.router);
1803        let layers = Arc::new(self.layers);
1804        let interceptors = Arc::new(self.interceptors);
1805
1806        let http1_server =
1807            Server::from_shared(router.clone(), layers.clone(), interceptors.clone());
1808        let http3_server =
1809            crate::http3::Http3Server::new(&config, router, layers, interceptors).await?;
1810
1811        tracing::info!(
1812            http1_addr = %http_addr,
1813            http3_addr = %config.socket_addr(),
1814            "Starting dual-stack HTTP/1.1 + HTTP/3 servers"
1815        );
1816
1817        tokio::try_join!(
1818            http1_server.run_with_shutdown(&http_addr, std::future::pending::<()>()),
1819            http3_server.run_with_shutdown(std::future::pending::<()>()),
1820        )?;
1821
1822        Ok(())
1823    }
1824}
1825
1826#[cfg(feature = "dashboard")]
1827fn openapi_tags_for_route(
1828    spec: &rustapi_openapi::OpenApiSpec,
1829    path: &str,
1830    methods: &[http::Method],
1831) -> Vec<String> {
1832    let Some(path_item) = spec.paths.get(path) else {
1833        return Vec::new();
1834    };
1835
1836    let mut tags = BTreeSet::new();
1837    for method in methods {
1838        if let Some(operation) = operation_for_method(path_item, method) {
1839            tags.extend(operation.tags.iter().cloned());
1840        }
1841    }
1842
1843    tags.into_iter().collect()
1844}
1845
1846#[cfg(feature = "dashboard")]
1847fn operation_for_method<'a>(
1848    path_item: &'a rustapi_openapi::PathItem,
1849    method: &http::Method,
1850) -> Option<&'a rustapi_openapi::Operation> {
1851    match *method {
1852        http::Method::GET => path_item.get.as_ref(),
1853        http::Method::POST => path_item.post.as_ref(),
1854        http::Method::PUT => path_item.put.as_ref(),
1855        http::Method::PATCH => path_item.patch.as_ref(),
1856        http::Method::DELETE => path_item.delete.as_ref(),
1857        http::Method::HEAD => path_item.head.as_ref(),
1858        http::Method::OPTIONS => path_item.options.as_ref(),
1859        http::Method::TRACE => path_item.trace.as_ref(),
1860        _ => None,
1861    }
1862}
1863
1864#[cfg(feature = "dashboard")]
1865fn infer_route_feature_gates(path: &str) -> Vec<String> {
1866    if path.contains("openapi") || path.contains("docs") {
1867        vec!["core-openapi".to_string()]
1868    } else if path.starts_with("/__rustapi/replays") {
1869        vec!["extras-replay".to_string()]
1870    } else {
1871        Vec::new()
1872    }
1873}
1874
1875#[cfg(feature = "dashboard")]
1876fn is_dashboard_replay_eligible(path: &str, health_eligible: bool) -> bool {
1877    !health_eligible && !path.starts_with("/__rustapi/")
1878}
1879
1880fn add_path_params_to_operation(
1881    path: &str,
1882    op: &mut rustapi_openapi::Operation,
1883    param_schemas: &BTreeMap<String, String>,
1884) {
1885    let mut params: Vec<String> = Vec::new();
1886    let mut in_brace = false;
1887    let mut current = String::new();
1888
1889    for ch in path.chars() {
1890        match ch {
1891            '{' => {
1892                in_brace = true;
1893                current.clear();
1894            }
1895            '}' => {
1896                if in_brace {
1897                    in_brace = false;
1898                    if !current.is_empty() {
1899                        params.push(current.clone());
1900                    }
1901                }
1902            }
1903            _ => {
1904                if in_brace {
1905                    current.push(ch);
1906                }
1907            }
1908        }
1909    }
1910
1911    if params.is_empty() {
1912        return;
1913    }
1914
1915    let op_params = &mut op.parameters;
1916
1917    for name in params {
1918        let already = op_params
1919            .iter()
1920            .any(|p| p.location == "path" && p.name == name);
1921        if already {
1922            continue;
1923        }
1924
1925        // Use custom schema if provided, otherwise infer from name
1926        let schema = if let Some(schema_type) = param_schemas.get(&name) {
1927            schema_type_to_openapi_schema(schema_type)
1928        } else {
1929            infer_path_param_schema(&name)
1930        };
1931
1932        op_params.push(rustapi_openapi::Parameter {
1933            name,
1934            location: "path".to_string(),
1935            required: true,
1936            description: None,
1937            deprecated: None,
1938            schema: Some(schema),
1939        });
1940    }
1941}
1942
1943/// Convert a schema type string to an OpenAPI schema reference
1944fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef {
1945    match schema_type.to_lowercase().as_str() {
1946        "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1947            "type": "string",
1948            "format": "uuid"
1949        })),
1950        "integer" | "int" | "int64" | "i64" => {
1951            rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1952                "type": "integer",
1953                "format": "int64"
1954            }))
1955        }
1956        "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1957            "type": "integer",
1958            "format": "int32"
1959        })),
1960        "number" | "float" | "f64" | "f32" => {
1961            rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1962                "type": "number"
1963            }))
1964        }
1965        "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1966            "type": "boolean"
1967        })),
1968        _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1969            "type": "string"
1970        })),
1971    }
1972}
1973
1974/// Infer the OpenAPI schema type for a path parameter based on naming conventions.
1975///
1976/// Common patterns:
1977/// - `*_id`, `*Id`, `id` → integer (but NOT *uuid)
1978/// - `*_count`, `*_num`, `page`, `limit`, `offset` → integer  
1979/// - `*_uuid`, `uuid` → string with uuid format
1980/// - `year`, `month`, `day` → integer
1981/// - Everything else → string
1982fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
1983    let lower = name.to_lowercase();
1984
1985    // UUID patterns (check first to avoid false positive from "id" suffix)
1986    let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
1987
1988    if is_uuid {
1989        return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
1990            "type": "string",
1991            "format": "uuid"
1992        }));
1993    }
1994
1995    // Integer patterns
1996    // Integer patterns
1997    let is_integer = lower == "page"
1998        || lower == "limit"
1999        || lower == "offset"
2000        || lower == "count"
2001        || lower.ends_with("_count")
2002        || lower.ends_with("_num")
2003        || lower == "year"
2004        || lower == "month"
2005        || lower == "day"
2006        || lower == "index"
2007        || lower == "position";
2008
2009    if is_integer {
2010        rustapi_openapi::SchemaRef::Inline(serde_json::json!({
2011            "type": "integer",
2012            "format": "int64"
2013        }))
2014    } else {
2015        rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
2016    }
2017}
2018
2019/// Normalize a prefix for OpenAPI paths.
2020///
2021/// Ensures the prefix:
2022/// - Starts with exactly one leading slash
2023/// - Has no trailing slash (unless it's just "/")
2024/// - Has no double slashes
2025fn normalize_prefix_for_openapi(prefix: &str) -> String {
2026    // Handle empty string
2027    if prefix.is_empty() {
2028        return "/".to_string();
2029    }
2030
2031    // Split by slashes and filter out empty segments (handles multiple slashes)
2032    let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
2033
2034    // If no segments after filtering, return root
2035    if segments.is_empty() {
2036        return "/".to_string();
2037    }
2038
2039    // Build the normalized prefix with leading slash
2040    let mut result = String::with_capacity(prefix.len() + 1);
2041    for segment in segments {
2042        result.push('/');
2043        result.push_str(segment);
2044    }
2045
2046    result
2047}
2048
2049impl Default for RustApi {
2050    fn default() -> Self {
2051        Self::new()
2052    }
2053}
2054
2055/// Check Basic Auth header against expected credentials
2056#[cfg(feature = "swagger-ui")]
2057fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
2058    req.headers()
2059        .get(http::header::AUTHORIZATION)
2060        .and_then(|v| v.to_str().ok())
2061        .map(|auth| auth == expected)
2062        .unwrap_or(false)
2063}
2064
2065/// Create 401 Unauthorized response with WWW-Authenticate header
2066#[cfg(feature = "swagger-ui")]
2067fn unauthorized_response() -> crate::Response {
2068    http::Response::builder()
2069        .status(http::StatusCode::UNAUTHORIZED)
2070        .header(
2071            http::header::WWW_AUTHENTICATE,
2072            "Basic realm=\"API Documentation\"",
2073        )
2074        .header(http::header::CONTENT_TYPE, "text/plain")
2075        .body(crate::response::Body::from("Unauthorized"))
2076        .unwrap()
2077}
2078
2079/// Configuration builder for RustAPI with auto-routes
2080pub struct RustApiConfig {
2081    docs_path: Option<String>,
2082    docs_enabled: bool,
2083    api_title: String,
2084    api_version: String,
2085    api_description: Option<String>,
2086    body_limit: Option<usize>,
2087    layers: LayerStack,
2088}
2089
2090impl Default for RustApiConfig {
2091    fn default() -> Self {
2092        Self::new()
2093    }
2094}
2095
2096impl RustApiConfig {
2097    pub fn new() -> Self {
2098        Self {
2099            docs_path: Some("/docs".to_string()),
2100            docs_enabled: true,
2101            api_title: "RustAPI".to_string(),
2102            api_version: "1.0.0".to_string(),
2103            api_description: None,
2104            body_limit: None,
2105            layers: LayerStack::new(),
2106        }
2107    }
2108
2109    /// Set the docs path (default: "/docs")
2110    pub fn docs_path(mut self, path: impl Into<String>) -> Self {
2111        self.docs_path = Some(path.into());
2112        self
2113    }
2114
2115    /// Enable or disable docs (default: true)
2116    pub fn docs_enabled(mut self, enabled: bool) -> Self {
2117        self.docs_enabled = enabled;
2118        self
2119    }
2120
2121    /// Set OpenAPI info
2122    pub fn openapi_info(
2123        mut self,
2124        title: impl Into<String>,
2125        version: impl Into<String>,
2126        description: Option<impl Into<String>>,
2127    ) -> Self {
2128        self.api_title = title.into();
2129        self.api_version = version.into();
2130        self.api_description = description.map(|d| d.into());
2131        self
2132    }
2133
2134    /// Set body size limit
2135    pub fn body_limit(mut self, limit: usize) -> Self {
2136        self.body_limit = Some(limit);
2137        self
2138    }
2139
2140    /// Add a middleware layer
2141    pub fn layer<L>(mut self, layer: L) -> Self
2142    where
2143        L: MiddlewareLayer,
2144    {
2145        self.layers.push(Box::new(layer));
2146        self
2147    }
2148
2149    /// Build the RustApi instance
2150    pub fn build(self) -> RustApi {
2151        let mut app = RustApi::new().mount_auto_routes_grouped();
2152
2153        // Apply configuration
2154        if let Some(limit) = self.body_limit {
2155            app = app.body_limit(limit);
2156        }
2157
2158        app = app.openapi_info(
2159            &self.api_title,
2160            &self.api_version,
2161            self.api_description.as_deref(),
2162        );
2163
2164        #[cfg(feature = "swagger-ui")]
2165        if self.docs_enabled {
2166            if let Some(path) = self.docs_path {
2167                app = app.docs(&path);
2168            }
2169        }
2170
2171        // Apply layers
2172        // Note: layers are applied in reverse order in RustApi::layer logic (pushing to vec)
2173        app.layers.extend(self.layers);
2174
2175        app
2176    }
2177
2178    /// Build and run the server
2179    pub async fn run(
2180        self,
2181        addr: impl AsRef<str>,
2182    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
2183        self.build().run(addr.as_ref()).await
2184    }
2185}
2186
2187#[cfg(test)]
2188mod tests {
2189    use super::RustApi;
2190    use crate::extract::{FromRequestParts, State};
2191    use crate::path_params::PathParams;
2192    use crate::request::Request;
2193    use crate::router::{get, post, Router};
2194    use bytes::Bytes;
2195    use http::Method;
2196    use proptest::prelude::*;
2197
2198    #[test]
2199    fn state_is_available_via_extractor() {
2200        let app = RustApi::new().state(123u32);
2201        let router = app.into_router();
2202
2203        let req = http::Request::builder()
2204            .method(Method::GET)
2205            .uri("/test")
2206            .body(())
2207            .unwrap();
2208        let (parts, _) = req.into_parts();
2209
2210        let request = Request::new(
2211            parts,
2212            crate::request::BodyVariant::Buffered(Bytes::new()),
2213            router.state_ref(),
2214            PathParams::new(),
2215        );
2216        let State(value) = State::<u32>::from_request_parts(&request).unwrap();
2217        assert_eq!(value, 123u32);
2218    }
2219
2220    #[test]
2221    fn test_path_param_type_inference_integer() {
2222        use super::infer_path_param_schema;
2223
2224        // Test common integer patterns
2225        let int_params = [
2226            "page",
2227            "limit",
2228            "offset",
2229            "count",
2230            "item_count",
2231            "year",
2232            "month",
2233            "day",
2234            "index",
2235            "position",
2236        ];
2237
2238        for name in int_params {
2239            let schema = infer_path_param_schema(name);
2240            match schema {
2241                rustapi_openapi::SchemaRef::Inline(v) => {
2242                    assert_eq!(
2243                        v.get("type").and_then(|v| v.as_str()),
2244                        Some("integer"),
2245                        "Expected '{}' to be inferred as integer",
2246                        name
2247                    );
2248                }
2249                _ => panic!("Expected inline schema for '{}'", name),
2250            }
2251        }
2252    }
2253
2254    #[test]
2255    fn test_path_param_type_inference_uuid() {
2256        use super::infer_path_param_schema;
2257
2258        // Test UUID patterns
2259        let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
2260
2261        for name in uuid_params {
2262            let schema = infer_path_param_schema(name);
2263            match schema {
2264                rustapi_openapi::SchemaRef::Inline(v) => {
2265                    assert_eq!(
2266                        v.get("type").and_then(|v| v.as_str()),
2267                        Some("string"),
2268                        "Expected '{}' to be inferred as string",
2269                        name
2270                    );
2271                    assert_eq!(
2272                        v.get("format").and_then(|v| v.as_str()),
2273                        Some("uuid"),
2274                        "Expected '{}' to have uuid format",
2275                        name
2276                    );
2277                }
2278                _ => panic!("Expected inline schema for '{}'", name),
2279            }
2280        }
2281    }
2282
2283    #[test]
2284    fn test_path_param_type_inference_string() {
2285        use super::infer_path_param_schema;
2286
2287        // Test string (default) patterns
2288        let string_params = [
2289            "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId",
2290        ];
2291
2292        for name in string_params {
2293            let schema = infer_path_param_schema(name);
2294            match schema {
2295                rustapi_openapi::SchemaRef::Inline(v) => {
2296                    assert_eq!(
2297                        v.get("type").and_then(|v| v.as_str()),
2298                        Some("string"),
2299                        "Expected '{}' to be inferred as string",
2300                        name
2301                    );
2302                    assert!(
2303                        v.get("format").is_none()
2304                            || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
2305                        "Expected '{}' to NOT have uuid format",
2306                        name
2307                    );
2308                }
2309                _ => panic!("Expected inline schema for '{}'", name),
2310            }
2311        }
2312    }
2313
2314    #[test]
2315    fn test_schema_type_to_openapi_schema() {
2316        use super::schema_type_to_openapi_schema;
2317
2318        // Test UUID schema
2319        let uuid_schema = schema_type_to_openapi_schema("uuid");
2320        match uuid_schema {
2321            rustapi_openapi::SchemaRef::Inline(v) => {
2322                assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2323                assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid"));
2324            }
2325            _ => panic!("Expected inline schema for uuid"),
2326        }
2327
2328        // Test integer schemas
2329        for schema_type in ["integer", "int", "int64", "i64"] {
2330            let schema = schema_type_to_openapi_schema(schema_type);
2331            match schema {
2332                rustapi_openapi::SchemaRef::Inline(v) => {
2333                    assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
2334                    assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64"));
2335                }
2336                _ => panic!("Expected inline schema for {}", schema_type),
2337            }
2338        }
2339
2340        // Test int32 schema
2341        let int32_schema = schema_type_to_openapi_schema("int32");
2342        match int32_schema {
2343            rustapi_openapi::SchemaRef::Inline(v) => {
2344                assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer"));
2345                assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32"));
2346            }
2347            _ => panic!("Expected inline schema for int32"),
2348        }
2349
2350        // Test number/float schema
2351        for schema_type in ["number", "float"] {
2352            let schema = schema_type_to_openapi_schema(schema_type);
2353            match schema {
2354                rustapi_openapi::SchemaRef::Inline(v) => {
2355                    assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number"));
2356                }
2357                _ => panic!("Expected inline schema for {}", schema_type),
2358            }
2359        }
2360
2361        // Test boolean schema
2362        for schema_type in ["boolean", "bool"] {
2363            let schema = schema_type_to_openapi_schema(schema_type);
2364            match schema {
2365                rustapi_openapi::SchemaRef::Inline(v) => {
2366                    assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean"));
2367                }
2368                _ => panic!("Expected inline schema for {}", schema_type),
2369            }
2370        }
2371
2372        // Test string schema (default)
2373        let string_schema = schema_type_to_openapi_schema("string");
2374        match string_schema {
2375            rustapi_openapi::SchemaRef::Inline(v) => {
2376                assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string"));
2377            }
2378            _ => panic!("Expected inline schema for string"),
2379        }
2380    }
2381
2382    // **Feature: router-nesting, Property 11: OpenAPI Integration**
2383    //
2384    // For any nested routes with OpenAPI operations, the operations should appear
2385    // in the parent's OpenAPI spec with prefixed paths and preserved metadata.
2386    //
2387    // **Validates: Requirements 4.1, 4.2**
2388    proptest! {
2389        #![proptest_config(ProptestConfig::with_cases(100))]
2390
2391        /// Property: Nested routes appear in OpenAPI spec with prefixed paths
2392        ///
2393        /// For any router with routes nested under a prefix, all routes should
2394        /// appear in the OpenAPI spec with the prefix prepended to their paths.
2395        #[test]
2396        fn prop_nested_routes_in_openapi_spec(
2397            // Generate prefix segments
2398            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2399            // Generate route path segments
2400            route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2401            has_param in any::<bool>(),
2402        ) {
2403            async fn handler() -> &'static str { "handler" }
2404
2405            // Build the prefix
2406            let prefix = format!("/{}", prefix_segments.join("/"));
2407
2408            // Build the route path
2409            let mut route_path = format!("/{}", route_segments.join("/"));
2410            if has_param {
2411                route_path.push_str("/{id}");
2412            }
2413
2414            // Create nested router and nest it through RustApi
2415            let nested_router = Router::new().route(&route_path, get(handler));
2416            let app = RustApi::new().nest(&prefix, nested_router);
2417
2418            // Build expected prefixed path for OpenAPI (uses {param} format)
2419            let expected_openapi_path = format!("{}{}", prefix, route_path);
2420
2421            // Get the OpenAPI spec
2422            let spec = app.openapi_spec();
2423
2424            // Property: The prefixed route should exist in OpenAPI paths
2425            prop_assert!(
2426                spec.paths.contains_key(&expected_openapi_path),
2427                "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2428                expected_openapi_path,
2429                spec.paths.keys().collect::<Vec<_>>()
2430            );
2431
2432            // Property: The path item should have a GET operation
2433            let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2434            prop_assert!(
2435                path_item.get.is_some(),
2436                "GET operation should exist for path '{}'",
2437                expected_openapi_path
2438            );
2439        }
2440
2441        /// Property: Multiple HTTP methods are preserved in OpenAPI spec after nesting
2442        ///
2443        /// For any router with routes having multiple HTTP methods, nesting should
2444        /// preserve all method operations in the OpenAPI spec.
2445        #[test]
2446        fn prop_multiple_methods_preserved_in_openapi(
2447            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2448            route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2449        ) {
2450            async fn get_handler() -> &'static str { "get" }
2451            async fn post_handler() -> &'static str { "post" }
2452
2453            // Build the prefix and route path
2454            let prefix = format!("/{}", prefix_segments.join("/"));
2455            let route_path = format!("/{}", route_segments.join("/"));
2456
2457            // Create nested router with both GET and POST using separate routes
2458            // Since MethodRouter doesn't have chaining methods, we create two routes
2459            let get_route_path = format!("{}/get", route_path);
2460            let post_route_path = format!("{}/post", route_path);
2461            let nested_router = Router::new()
2462                .route(&get_route_path, get(get_handler))
2463                .route(&post_route_path, post(post_handler));
2464            let app = RustApi::new().nest(&prefix, nested_router);
2465
2466            // Build expected prefixed paths for OpenAPI
2467            let expected_get_path = format!("{}{}", prefix, get_route_path);
2468            let expected_post_path = format!("{}{}", prefix, post_route_path);
2469
2470            // Get the OpenAPI spec
2471            let spec = app.openapi_spec();
2472
2473            // Property: Both paths should exist
2474            prop_assert!(
2475                spec.paths.contains_key(&expected_get_path),
2476                "Expected OpenAPI path '{}' not found",
2477                expected_get_path
2478            );
2479            prop_assert!(
2480                spec.paths.contains_key(&expected_post_path),
2481                "Expected OpenAPI path '{}' not found",
2482                expected_post_path
2483            );
2484
2485            // Property: GET operation should exist on get path
2486            let get_path_item = spec.paths.get(&expected_get_path).unwrap();
2487            prop_assert!(
2488                get_path_item.get.is_some(),
2489                "GET operation should exist for path '{}'",
2490                expected_get_path
2491            );
2492
2493            // Property: POST operation should exist on post path
2494            let post_path_item = spec.paths.get(&expected_post_path).unwrap();
2495            prop_assert!(
2496                post_path_item.post.is_some(),
2497                "POST operation should exist for path '{}'",
2498                expected_post_path
2499            );
2500        }
2501
2502        /// Property: Path parameters are added to OpenAPI operations after nesting
2503        ///
2504        /// For any nested route with path parameters, the OpenAPI operation should
2505        /// include the path parameters.
2506        #[test]
2507        fn prop_path_params_in_openapi_after_nesting(
2508            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2509            param_name in "[a-z][a-z0-9]{0,5}",
2510        ) {
2511            async fn handler() -> &'static str { "handler" }
2512
2513            // Build the prefix and route path with parameter
2514            let prefix = format!("/{}", prefix_segments.join("/"));
2515            let route_path = format!("/{{{}}}", param_name);
2516
2517            // Create nested router
2518            let nested_router = Router::new().route(&route_path, get(handler));
2519            let app = RustApi::new().nest(&prefix, nested_router);
2520
2521            // Build expected prefixed path for OpenAPI
2522            let expected_openapi_path = format!("{}{}", prefix, route_path);
2523
2524            // Get the OpenAPI spec
2525            let spec = app.openapi_spec();
2526
2527            // Property: The path should exist
2528            prop_assert!(
2529                spec.paths.contains_key(&expected_openapi_path),
2530                "Expected OpenAPI path '{}' not found",
2531                expected_openapi_path
2532            );
2533
2534            // Property: The GET operation should have the path parameter
2535            let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2536            let get_op = path_item.get.as_ref().unwrap();
2537
2538            prop_assert!(
2539                !get_op.parameters.is_empty(),
2540                "Operation should have parameters for path '{}'",
2541                expected_openapi_path
2542            );
2543
2544            let params = &get_op.parameters;
2545            let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
2546            prop_assert!(
2547                has_param,
2548                "Path parameter '{}' should exist in operation parameters. Found: {:?}",
2549                param_name,
2550                params.iter().map(|p| &p.name).collect::<Vec<_>>()
2551            );
2552        }
2553    }
2554
2555    // **Feature: router-nesting, Property 13: RustApi Integration**
2556    //
2557    // For any router nested through `RustApi::new().nest()`, the behavior should be
2558    // identical to nesting through `Router::new().nest()`, and routes should appear
2559    // in the OpenAPI spec.
2560    //
2561    // **Validates: Requirements 6.1, 6.2**
2562    proptest! {
2563        #![proptest_config(ProptestConfig::with_cases(100))]
2564
2565        /// Property: RustApi::nest delegates to Router::nest and produces identical route registration
2566        ///
2567        /// For any router with routes nested under a prefix, nesting through RustApi
2568        /// should produce the same route registration as nesting through Router directly.
2569        #[test]
2570        fn prop_rustapi_nest_delegates_to_router_nest(
2571            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2572            route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2573            has_param in any::<bool>(),
2574        ) {
2575            async fn handler() -> &'static str { "handler" }
2576
2577            // Build the prefix
2578            let prefix = format!("/{}", prefix_segments.join("/"));
2579
2580            // Build the route path
2581            let mut route_path = format!("/{}", route_segments.join("/"));
2582            if has_param {
2583                route_path.push_str("/{id}");
2584            }
2585
2586            // Create nested router
2587            let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2588            let nested_router_for_router = Router::new().route(&route_path, get(handler));
2589
2590            // Nest through RustApi
2591            let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2592            let rustapi_router = rustapi_app.into_router();
2593
2594            // Nest through Router directly
2595            let router_app = Router::new().nest(&prefix, nested_router_for_router);
2596
2597            // Property: Both should have the same registered routes
2598            let rustapi_routes = rustapi_router.registered_routes();
2599            let router_routes = router_app.registered_routes();
2600
2601            prop_assert_eq!(
2602                rustapi_routes.len(),
2603                router_routes.len(),
2604                "RustApi and Router should have same number of routes"
2605            );
2606
2607            // Property: All routes from Router should exist in RustApi
2608            for (path, info) in router_routes {
2609                prop_assert!(
2610                    rustapi_routes.contains_key(path),
2611                    "Route '{}' from Router should exist in RustApi routes",
2612                    path
2613                );
2614
2615                let rustapi_info = rustapi_routes.get(path).unwrap();
2616                prop_assert_eq!(
2617                    &info.path, &rustapi_info.path,
2618                    "Display paths should match for route '{}'",
2619                    path
2620                );
2621                prop_assert_eq!(
2622                    info.methods.len(), rustapi_info.methods.len(),
2623                    "Method count should match for route '{}'",
2624                    path
2625                );
2626            }
2627        }
2628
2629        /// Property: RustApi::nest includes nested routes in OpenAPI spec
2630        ///
2631        /// For any router with routes nested through RustApi, all routes should
2632        /// appear in the OpenAPI specification with prefixed paths.
2633        #[test]
2634        fn prop_rustapi_nest_includes_routes_in_openapi(
2635            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2636            route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
2637            has_param in any::<bool>(),
2638        ) {
2639            async fn handler() -> &'static str { "handler" }
2640
2641            // Build the prefix
2642            let prefix = format!("/{}", prefix_segments.join("/"));
2643
2644            // Build the route path
2645            let mut route_path = format!("/{}", route_segments.join("/"));
2646            if has_param {
2647                route_path.push_str("/{id}");
2648            }
2649
2650            // Create nested router and nest through RustApi
2651            let nested_router = Router::new().route(&route_path, get(handler));
2652            let app = RustApi::new().nest(&prefix, nested_router);
2653
2654            // Build expected prefixed path for OpenAPI
2655            let expected_openapi_path = format!("{}{}", prefix, route_path);
2656
2657            // Get the OpenAPI spec
2658            let spec = app.openapi_spec();
2659
2660            // Property: The prefixed route should exist in OpenAPI paths
2661            prop_assert!(
2662                spec.paths.contains_key(&expected_openapi_path),
2663                "Expected OpenAPI path '{}' not found. Available paths: {:?}",
2664                expected_openapi_path,
2665                spec.paths.keys().collect::<Vec<_>>()
2666            );
2667
2668            // Property: The path item should have a GET operation
2669            let path_item = spec.paths.get(&expected_openapi_path).unwrap();
2670            prop_assert!(
2671                path_item.get.is_some(),
2672                "GET operation should exist for path '{}'",
2673                expected_openapi_path
2674            );
2675        }
2676
2677        /// Property: RustApi::nest route matching is identical to Router::nest
2678        ///
2679        /// For any nested route, matching through RustApi should produce the same
2680        /// result as matching through Router directly.
2681        #[test]
2682        fn prop_rustapi_nest_route_matching_identical(
2683            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2684            route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
2685            param_value in "[a-z0-9]{1,10}",
2686        ) {
2687            use crate::router::RouteMatch;
2688
2689            async fn handler() -> &'static str { "handler" }
2690
2691            // Build the prefix and route path with parameter
2692            let prefix = format!("/{}", prefix_segments.join("/"));
2693            let route_path = format!("/{}/{{id}}", route_segments.join("/"));
2694
2695            // Create nested routers
2696            let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
2697            let nested_router_for_router = Router::new().route(&route_path, get(handler));
2698
2699            // Nest through both RustApi and Router
2700            let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
2701            let rustapi_router = rustapi_app.into_router();
2702            let router_app = Router::new().nest(&prefix, nested_router_for_router);
2703
2704            // Build the full path to match
2705            let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
2706
2707            // Match through both
2708            let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
2709            let router_match = router_app.match_route(&full_path, &Method::GET);
2710
2711            // Property: Both should return Found with same parameters
2712            match (rustapi_match, router_match) {
2713                (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
2714                    prop_assert_eq!(
2715                        rustapi_params.len(),
2716                        router_params.len(),
2717                        "Parameter count should match"
2718                    );
2719                    for (key, value) in &router_params {
2720                        prop_assert!(
2721                            rustapi_params.contains_key(key),
2722                            "RustApi should have parameter '{}'",
2723                            key
2724                        );
2725                        prop_assert_eq!(
2726                            rustapi_params.get(key).unwrap(),
2727                            value,
2728                            "Parameter '{}' value should match",
2729                            key
2730                        );
2731                    }
2732                }
2733                (rustapi_result, router_result) => {
2734                    prop_assert!(
2735                        false,
2736                        "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
2737                        match rustapi_result {
2738                            RouteMatch::Found { .. } => "Found",
2739                            RouteMatch::NotFound => "NotFound",
2740                            RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2741                        },
2742                        match router_result {
2743                            RouteMatch::Found { .. } => "Found",
2744                            RouteMatch::NotFound => "NotFound",
2745                            RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
2746                        }
2747                    );
2748                }
2749            }
2750        }
2751    }
2752
2753    /// Unit test: Verify OpenAPI operations are propagated during nesting
2754    #[test]
2755    fn test_openapi_operations_propagated_during_nesting() {
2756        async fn list_users() -> &'static str {
2757            "list users"
2758        }
2759        async fn get_user() -> &'static str {
2760            "get user"
2761        }
2762        async fn create_user() -> &'static str {
2763            "create user"
2764        }
2765
2766        // Create nested router with multiple routes
2767        // Note: We use separate routes since MethodRouter doesn't support chaining
2768        let users_router = Router::new()
2769            .route("/", get(list_users))
2770            .route("/create", post(create_user))
2771            .route("/{id}", get(get_user));
2772
2773        // Nest under /api/v1/users
2774        let app = RustApi::new().nest("/api/v1/users", users_router);
2775
2776        let spec = app.openapi_spec();
2777
2778        // Verify /api/v1/users path exists with GET
2779        assert!(
2780            spec.paths.contains_key("/api/v1/users"),
2781            "Should have /api/v1/users path"
2782        );
2783        let users_path = spec.paths.get("/api/v1/users").unwrap();
2784        assert!(users_path.get.is_some(), "Should have GET operation");
2785
2786        // Verify /api/v1/users/create path exists with POST
2787        assert!(
2788            spec.paths.contains_key("/api/v1/users/create"),
2789            "Should have /api/v1/users/create path"
2790        );
2791        let create_path = spec.paths.get("/api/v1/users/create").unwrap();
2792        assert!(create_path.post.is_some(), "Should have POST operation");
2793
2794        // Verify /api/v1/users/{id} path exists with GET
2795        assert!(
2796            spec.paths.contains_key("/api/v1/users/{id}"),
2797            "Should have /api/v1/users/{{id}} path"
2798        );
2799        let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
2800        assert!(
2801            user_path.get.is_some(),
2802            "Should have GET operation for user by id"
2803        );
2804
2805        // Verify path parameter is added
2806        let get_user_op = user_path.get.as_ref().unwrap();
2807        assert!(!get_user_op.parameters.is_empty(), "Should have parameters");
2808        let params = &get_user_op.parameters;
2809        assert!(
2810            params
2811                .iter()
2812                .any(|p| p.name == "id" && p.location == "path"),
2813            "Should have 'id' path parameter"
2814        );
2815    }
2816
2817    /// Unit test: Verify nested routes don't appear without nesting
2818    #[test]
2819    fn test_openapi_spec_empty_without_routes() {
2820        let app = RustApi::new();
2821        let spec = app.openapi_spec();
2822
2823        // Should have no paths (except potentially default ones)
2824        assert!(
2825            spec.paths.is_empty(),
2826            "OpenAPI spec should have no paths without routes"
2827        );
2828    }
2829
2830    /// Unit test: Verify RustApi::nest delegates correctly to Router::nest
2831    ///
2832    /// **Feature: router-nesting, Property 13: RustApi Integration**
2833    /// **Validates: Requirements 6.1, 6.2**
2834    #[test]
2835    fn test_rustapi_nest_delegates_to_router_nest() {
2836        use crate::router::RouteMatch;
2837
2838        async fn list_users() -> &'static str {
2839            "list users"
2840        }
2841        async fn get_user() -> &'static str {
2842            "get user"
2843        }
2844        async fn create_user() -> &'static str {
2845            "create user"
2846        }
2847
2848        // Create nested router with multiple routes
2849        let users_router = Router::new()
2850            .route("/", get(list_users))
2851            .route("/create", post(create_user))
2852            .route("/{id}", get(get_user));
2853
2854        // Nest through RustApi
2855        let app = RustApi::new().nest("/api/v1/users", users_router);
2856        let router = app.into_router();
2857
2858        // Verify routes are registered correctly
2859        let routes = router.registered_routes();
2860        assert_eq!(routes.len(), 3, "Should have 3 routes registered");
2861
2862        // Verify route paths
2863        assert!(
2864            routes.contains_key("/api/v1/users"),
2865            "Should have /api/v1/users route"
2866        );
2867        assert!(
2868            routes.contains_key("/api/v1/users/create"),
2869            "Should have /api/v1/users/create route"
2870        );
2871        assert!(
2872            routes.contains_key("/api/v1/users/:id"),
2873            "Should have /api/v1/users/:id route"
2874        );
2875
2876        // Verify route matching works
2877        match router.match_route("/api/v1/users", &Method::GET) {
2878            RouteMatch::Found { params, .. } => {
2879                assert!(params.is_empty(), "Root route should have no params");
2880            }
2881            _ => panic!("GET /api/v1/users should be found"),
2882        }
2883
2884        match router.match_route("/api/v1/users/create", &Method::POST) {
2885            RouteMatch::Found { params, .. } => {
2886                assert!(params.is_empty(), "Create route should have no params");
2887            }
2888            _ => panic!("POST /api/v1/users/create should be found"),
2889        }
2890
2891        match router.match_route("/api/v1/users/123", &Method::GET) {
2892            RouteMatch::Found { params, .. } => {
2893                assert_eq!(
2894                    params.get("id"),
2895                    Some(&"123".to_string()),
2896                    "Should extract id param"
2897                );
2898            }
2899            _ => panic!("GET /api/v1/users/123 should be found"),
2900        }
2901
2902        // Verify method not allowed
2903        match router.match_route("/api/v1/users", &Method::DELETE) {
2904            RouteMatch::MethodNotAllowed { allowed } => {
2905                assert!(allowed.contains(&Method::GET), "Should allow GET");
2906            }
2907            _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
2908        }
2909    }
2910
2911    /// Unit test: Verify RustApi::nest includes routes in OpenAPI spec
2912    ///
2913    /// **Feature: router-nesting, Property 13: RustApi Integration**
2914    /// **Validates: Requirements 6.1, 6.2**
2915    #[test]
2916    fn test_rustapi_nest_includes_routes_in_openapi_spec() {
2917        async fn list_items() -> &'static str {
2918            "list items"
2919        }
2920        async fn get_item() -> &'static str {
2921            "get item"
2922        }
2923
2924        // Create nested router
2925        let items_router = Router::new()
2926            .route("/", get(list_items))
2927            .route("/{item_id}", get(get_item));
2928
2929        // Nest through RustApi
2930        let app = RustApi::new().nest("/api/items", items_router);
2931
2932        // Verify OpenAPI spec
2933        let spec = app.openapi_spec();
2934
2935        // Verify paths exist
2936        assert!(
2937            spec.paths.contains_key("/api/items"),
2938            "Should have /api/items in OpenAPI"
2939        );
2940        assert!(
2941            spec.paths.contains_key("/api/items/{item_id}"),
2942            "Should have /api/items/{{item_id}} in OpenAPI"
2943        );
2944
2945        // Verify operations
2946        let list_path = spec.paths.get("/api/items").unwrap();
2947        assert!(
2948            list_path.get.is_some(),
2949            "Should have GET operation for /api/items"
2950        );
2951
2952        let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
2953        assert!(
2954            get_path.get.is_some(),
2955            "Should have GET operation for /api/items/{{item_id}}"
2956        );
2957
2958        // Verify path parameter is added
2959        let get_op = get_path.get.as_ref().unwrap();
2960        assert!(!get_op.parameters.is_empty(), "Should have parameters");
2961        let params = &get_op.parameters;
2962        assert!(
2963            params
2964                .iter()
2965                .any(|p| p.name == "item_id" && p.location == "path"),
2966            "Should have 'item_id' path parameter"
2967        );
2968    }
2969}