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