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