rustapi_core/
app.rs

1//! RustApi application builder
2
3use crate::error::Result;
4use crate::interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor};
5use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT};
6use crate::response::IntoResponse;
7use crate::router::{MethodRouter, Router};
8use crate::server::Server;
9use std::collections::HashMap;
10use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
11
12/// Main application builder for RustAPI
13///
14/// # Example
15///
16/// ```rust,ignore
17/// use rustapi_rs::prelude::*;
18///
19/// #[tokio::main]
20/// async fn main() -> Result<()> {
21///     RustApi::new()
22///         .state(AppState::new())
23///         .route("/", get(hello))
24///         .route("/users/{id}", get(get_user))
25///         .run("127.0.0.1:8080")
26///         .await
27/// }
28/// ```
29pub struct RustApi {
30    router: Router,
31    openapi_spec: rustapi_openapi::OpenApiSpec,
32    layers: LayerStack,
33    body_limit: Option<usize>,
34    interceptors: InterceptorChain,
35}
36
37impl RustApi {
38    /// Create a new RustAPI application
39    pub fn new() -> Self {
40        // Initialize tracing if not already done
41        let _ = tracing_subscriber::registry()
42            .with(
43                EnvFilter::try_from_default_env()
44                    .unwrap_or_else(|_| EnvFilter::new("info,rustapi=debug")),
45            )
46            .with(tracing_subscriber::fmt::layer())
47            .try_init();
48
49        Self {
50            router: Router::new(),
51            openapi_spec: rustapi_openapi::OpenApiSpec::new("RustAPI Application", "1.0.0")
52                .register::<rustapi_openapi::ErrorSchema>()
53                .register::<rustapi_openapi::ErrorBodySchema>()
54                .register::<rustapi_openapi::ValidationErrorSchema>()
55                .register::<rustapi_openapi::ValidationErrorBodySchema>()
56                .register::<rustapi_openapi::FieldErrorSchema>(),
57            layers: LayerStack::new(),
58            body_limit: Some(DEFAULT_BODY_LIMIT), // Default 1MB limit
59            interceptors: InterceptorChain::new(),
60        }
61    }
62
63    /// Create a zero-config RustAPI application.
64    ///
65    /// All routes decorated with `#[rustapi::get]`, `#[rustapi::post]`, etc.
66    /// are automatically registered. Swagger UI is enabled at `/docs` by default.
67    ///
68    /// # Example
69    ///
70    /// ```rust,ignore
71    /// use rustapi_rs::prelude::*;
72    ///
73    /// #[rustapi::get("/users")]
74    /// async fn list_users() -> Json<Vec<User>> {
75    ///     Json(vec![])
76    /// }
77    ///
78    /// #[rustapi::main]
79    /// async fn main() -> Result<()> {
80    ///     // Zero config - routes are auto-registered!
81    ///     RustApi::auto()
82    ///         .run("0.0.0.0:8080")
83    ///         .await
84    /// }
85    /// ```
86    #[cfg(feature = "swagger-ui")]
87    pub fn auto() -> Self {
88        // Build app with grouped auto-routes and auto-schemas, then enable docs.
89        Self::new().mount_auto_routes_grouped().docs("/docs")
90    }
91
92    /// Create a zero-config RustAPI application (without swagger-ui feature).
93    ///
94    /// All routes decorated with `#[rustapi::get]`, `#[rustapi::post]`, etc.
95    /// are automatically registered.
96    #[cfg(not(feature = "swagger-ui"))]
97    pub fn auto() -> Self {
98        Self::new().mount_auto_routes_grouped()
99    }
100
101    /// Create a configurable RustAPI application with auto-routes.
102    ///
103    /// Provides builder methods for customization while still
104    /// auto-registering all decorated routes.
105    ///
106    /// # Example
107    ///
108    /// ```rust,ignore
109    /// use rustapi_rs::prelude::*;
110    ///
111    /// RustApi::config()
112    ///     .docs_path("/api-docs")
113    ///     .body_limit(5 * 1024 * 1024)  // 5MB
114    ///     .openapi_info("My API", "2.0.0", Some("API Description"))
115    ///     .run("0.0.0.0:8080")
116    ///     .await?;
117    /// ```
118    pub fn config() -> RustApiConfig {
119        RustApiConfig::new()
120    }
121
122    /// Set the global body size limit for request bodies
123    ///
124    /// This protects against denial-of-service attacks via large payloads.
125    /// The default limit is 1MB (1024 * 1024 bytes).
126    ///
127    /// # Arguments
128    ///
129    /// * `limit` - Maximum body size in bytes
130    ///
131    /// # Example
132    ///
133    /// ```rust,ignore
134    /// use rustapi_rs::prelude::*;
135    ///
136    /// RustApi::new()
137    ///     .body_limit(5 * 1024 * 1024)  // 5MB limit
138    ///     .route("/upload", post(upload_handler))
139    ///     .run("127.0.0.1:8080")
140    ///     .await
141    /// ```
142    pub fn body_limit(mut self, limit: usize) -> Self {
143        self.body_limit = Some(limit);
144        self
145    }
146
147    /// Disable the body size limit
148    ///
149    /// Warning: This removes protection against large payload attacks.
150    /// Only use this if you have other mechanisms to limit request sizes.
151    ///
152    /// # Example
153    ///
154    /// ```rust,ignore
155    /// RustApi::new()
156    ///     .no_body_limit()  // Disable body size limit
157    ///     .route("/upload", post(upload_handler))
158    /// ```
159    pub fn no_body_limit(mut self) -> Self {
160        self.body_limit = None;
161        self
162    }
163
164    /// Add a middleware layer to the application
165    ///
166    /// Layers are executed in the order they are added (outermost first).
167    /// The first layer added will be the first to process the request and
168    /// the last to process the response.
169    ///
170    /// # Example
171    ///
172    /// ```rust,ignore
173    /// use rustapi_rs::prelude::*;
174    /// use rustapi_core::middleware::{RequestIdLayer, TracingLayer};
175    ///
176    /// RustApi::new()
177    ///     .layer(RequestIdLayer::new())  // First to process request
178    ///     .layer(TracingLayer::new())    // Second to process request
179    ///     .route("/", get(handler))
180    ///     .run("127.0.0.1:8080")
181    ///     .await
182    /// ```
183    pub fn layer<L>(mut self, layer: L) -> Self
184    where
185        L: MiddlewareLayer,
186    {
187        self.layers.push(Box::new(layer));
188        self
189    }
190
191    /// Add a request interceptor to the application
192    ///
193    /// Request interceptors are executed in registration order before the route handler.
194    /// Each interceptor can modify the request before passing it to the next interceptor
195    /// or handler.
196    ///
197    /// # Example
198    ///
199    /// ```rust,ignore
200    /// use rustapi_core::{RustApi, interceptor::RequestInterceptor, Request};
201    ///
202    /// #[derive(Clone)]
203    /// struct AddRequestId;
204    ///
205    /// impl RequestInterceptor for AddRequestId {
206    ///     fn intercept(&self, mut req: Request) -> Request {
207    ///         req.extensions_mut().insert(uuid::Uuid::new_v4());
208    ///         req
209    ///     }
210    ///
211    ///     fn clone_box(&self) -> Box<dyn RequestInterceptor> {
212    ///         Box::new(self.clone())
213    ///     }
214    /// }
215    ///
216    /// RustApi::new()
217    ///     .request_interceptor(AddRequestId)
218    ///     .route("/", get(handler))
219    ///     .run("127.0.0.1:8080")
220    ///     .await
221    /// ```
222    pub fn request_interceptor<I>(mut self, interceptor: I) -> Self
223    where
224        I: RequestInterceptor,
225    {
226        self.interceptors.add_request_interceptor(interceptor);
227        self
228    }
229
230    /// Add a response interceptor to the application
231    ///
232    /// Response interceptors are executed in reverse registration order after the route
233    /// handler completes. Each interceptor can modify the response before passing it
234    /// to the previous interceptor or client.
235    ///
236    /// # Example
237    ///
238    /// ```rust,ignore
239    /// use rustapi_core::{RustApi, interceptor::ResponseInterceptor, Response};
240    ///
241    /// #[derive(Clone)]
242    /// struct AddServerHeader;
243    ///
244    /// impl ResponseInterceptor for AddServerHeader {
245    ///     fn intercept(&self, mut res: Response) -> Response {
246    ///         res.headers_mut().insert("X-Server", "RustAPI".parse().unwrap());
247    ///         res
248    ///     }
249    ///
250    ///     fn clone_box(&self) -> Box<dyn ResponseInterceptor> {
251    ///         Box::new(self.clone())
252    ///     }
253    /// }
254    ///
255    /// RustApi::new()
256    ///     .response_interceptor(AddServerHeader)
257    ///     .route("/", get(handler))
258    ///     .run("127.0.0.1:8080")
259    ///     .await
260    /// ```
261    pub fn response_interceptor<I>(mut self, interceptor: I) -> Self
262    where
263        I: ResponseInterceptor,
264    {
265        self.interceptors.add_response_interceptor(interceptor);
266        self
267    }
268
269    /// Add application state
270    ///
271    /// State is shared across all handlers and can be extracted using `State<T>`.
272    ///
273    /// # Example
274    ///
275    /// ```rust,ignore
276    /// #[derive(Clone)]
277    /// struct AppState {
278    ///     db: DbPool,
279    /// }
280    ///
281    /// RustApi::new()
282    ///     .state(AppState::new())
283    /// ```
284    pub fn state<S>(self, _state: S) -> Self
285    where
286        S: Clone + Send + Sync + 'static,
287    {
288        // Store state in the router's shared Extensions so `State<T>` extractor can retrieve it.
289        let state = _state;
290        let mut app = self;
291        app.router = app.router.state(state);
292        app
293    }
294
295    /// Register an OpenAPI schema
296    ///
297    /// # Example
298    ///
299    /// ```rust,ignore
300    /// #[derive(Schema)]
301    /// struct User { ... }
302    ///
303    /// RustApi::new()
304    ///     .register_schema::<User>()
305    /// ```
306    pub fn register_schema<T: for<'a> rustapi_openapi::Schema<'a>>(mut self) -> Self {
307        self.openapi_spec = self.openapi_spec.register::<T>();
308        self
309    }
310
311    /// Configure OpenAPI info (title, version, description)
312    pub fn openapi_info(mut self, title: &str, version: &str, description: Option<&str>) -> Self {
313        // NOTE: Do not reset the spec here; doing so would drop collected paths/schemas.
314        // This is especially important for `RustApi::auto()` and `RustApi::config()`.
315        self.openapi_spec.info.title = title.to_string();
316        self.openapi_spec.info.version = version.to_string();
317        self.openapi_spec.info.description = description.map(|d| d.to_string());
318        self
319    }
320
321    /// Get the current OpenAPI spec (for advanced usage/testing).
322    pub fn openapi_spec(&self) -> &rustapi_openapi::OpenApiSpec {
323        &self.openapi_spec
324    }
325
326    fn mount_auto_routes_grouped(mut self) -> Self {
327        let routes = crate::auto_route::collect_auto_routes();
328        let mut by_path: HashMap<String, MethodRouter> = HashMap::new();
329
330        for route in routes {
331            let method_enum = match route.method {
332                "GET" => http::Method::GET,
333                "POST" => http::Method::POST,
334                "PUT" => http::Method::PUT,
335                "DELETE" => http::Method::DELETE,
336                "PATCH" => http::Method::PATCH,
337                _ => http::Method::GET,
338            };
339
340            let path = if route.path.starts_with('/') {
341                route.path.to_string()
342            } else {
343                format!("/{}", route.path)
344            };
345
346            let entry = by_path.entry(path).or_default();
347            entry.insert_boxed_with_operation(method_enum, route.handler, route.operation);
348        }
349
350        #[cfg(feature = "tracing")]
351        let route_count: usize = by_path.values().map(|mr| mr.allowed_methods().len()).sum();
352        #[cfg(feature = "tracing")]
353        let path_count = by_path.len();
354
355        for (path, method_router) in by_path {
356            self = self.route(&path, method_router);
357        }
358
359        crate::trace_info!(
360            paths = path_count,
361            routes = route_count,
362            "Auto-registered routes"
363        );
364
365        // Apply any auto-registered schemas.
366        crate::auto_schema::apply_auto_schemas(&mut self.openapi_spec);
367
368        self
369    }
370
371    /// Add a route
372    ///
373    /// # Example
374    ///
375    /// ```rust,ignore
376    /// RustApi::new()
377    ///     .route("/", get(index))
378    ///     .route("/users", get(list_users).post(create_user))
379    ///     .route("/users/{id}", get(get_user).delete(delete_user))
380    /// ```
381    pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self {
382        // Register operations in OpenAPI spec
383        for (method, op) in &method_router.operations {
384            let mut op = op.clone();
385            add_path_params_to_operation(path, &mut op);
386            self.openapi_spec = self.openapi_spec.path(path, method.as_str(), op);
387        }
388
389        self.router = self.router.route(path, method_router);
390        self
391    }
392
393    /// Add a typed route
394    pub fn typed<P: crate::typed_path::TypedPath>(self, method_router: MethodRouter) -> Self {
395        self.route(P::PATH, method_router)
396    }
397
398    /// Mount a handler (convenience method)
399    ///
400    /// Alias for `.route(path, method_router)` for a single handler.
401    #[deprecated(note = "Use route() directly or mount_route() for macro-based routing")]
402    pub fn mount(self, path: &str, method_router: MethodRouter) -> Self {
403        self.route(path, method_router)
404    }
405
406    /// Mount a route created with `#[rustapi::get]`, `#[rustapi::post]`, etc.
407    ///
408    /// # Example
409    ///
410    /// ```rust,ignore
411    /// use rustapi_rs::prelude::*;
412    ///
413    /// #[rustapi::get("/users")]
414    /// async fn list_users() -> Json<Vec<User>> {
415    ///     Json(vec![])
416    /// }
417    ///
418    /// RustApi::new()
419    ///     .mount_route(route!(list_users))
420    ///     .run("127.0.0.1:8080")
421    ///     .await
422    /// ```
423    pub fn mount_route(mut self, route: crate::handler::Route) -> Self {
424        let method_enum = match route.method {
425            "GET" => http::Method::GET,
426            "POST" => http::Method::POST,
427            "PUT" => http::Method::PUT,
428            "DELETE" => http::Method::DELETE,
429            "PATCH" => http::Method::PATCH,
430            _ => http::Method::GET,
431        };
432
433        // Register operation in OpenAPI spec
434        let mut op = route.operation;
435        add_path_params_to_operation(route.path, &mut op);
436        self.openapi_spec = self.openapi_spec.path(route.path, route.method, op);
437
438        self.route_with_method(route.path, method_enum, route.handler)
439    }
440
441    /// Helper to mount a single method handler
442    fn route_with_method(
443        self,
444        path: &str,
445        method: http::Method,
446        handler: crate::handler::BoxedHandler,
447    ) -> Self {
448        use crate::router::MethodRouter;
449        // use http::Method; // Removed
450
451        // This is simplified. In a real implementation we'd merge with existing router at this path
452        // For now we assume one handler per path or we simply allow overwriting for this MVP step
453        // (matchit router doesn't allow easy merging/updating existing entries without rebuilding)
454        //
455        // TOOD: Enhance Router to support method merging
456
457        let path = if !path.starts_with('/') {
458            format!("/{}", path)
459        } else {
460            path.to_string()
461        };
462
463        // Check if we already have this path?
464        // For MVP, valid assumption: user calls .route() or .mount() once per path-method-combo
465        // But we need to handle multiple methods on same path.
466        // Our Router wrapper currently just inserts.
467
468        // Since we can't easily query matchit, we'll just insert.
469        // Limitations: strictly sequential mounting for now.
470
471        let mut handlers = std::collections::HashMap::new();
472        handlers.insert(method, handler);
473
474        let method_router = MethodRouter::from_boxed(handlers);
475        self.route(&path, method_router)
476    }
477
478    /// Nest a router under a prefix
479    ///
480    /// All routes from the nested router will be registered with the prefix
481    /// prepended to their paths. OpenAPI operations from the nested router
482    /// are also propagated to the parent's OpenAPI spec with prefixed paths.
483    ///
484    /// # Example
485    ///
486    /// ```rust,ignore
487    /// let api_v1 = Router::new()
488    ///     .route("/users", get(list_users));
489    ///
490    /// RustApi::new()
491    ///     .nest("/api/v1", api_v1)
492    /// ```
493    pub fn nest(mut self, prefix: &str, router: Router) -> Self {
494        // Normalize the prefix for OpenAPI paths
495        let normalized_prefix = normalize_prefix_for_openapi(prefix);
496
497        // Propagate OpenAPI operations from nested router with prefixed paths
498        // We need to do this before calling router.nest() because it consumes the router
499        for (matchit_path, method_router) in router.method_routers() {
500            // Get the display path from registered_routes (has {param} format)
501            let display_path = router
502                .registered_routes()
503                .get(matchit_path)
504                .map(|info| info.path.clone())
505                .unwrap_or_else(|| matchit_path.clone());
506
507            // Build the prefixed display path for OpenAPI
508            let prefixed_path = if display_path == "/" {
509                normalized_prefix.clone()
510            } else {
511                format!("{}{}", normalized_prefix, display_path)
512            };
513
514            // Register each operation in the OpenAPI spec
515            for (method, op) in &method_router.operations {
516                let mut op = op.clone();
517                add_path_params_to_operation(&prefixed_path, &mut op);
518                self.openapi_spec = self.openapi_spec.path(&prefixed_path, method.as_str(), op);
519            }
520        }
521
522        // Delegate to Router::nest for actual route registration
523        self.router = self.router.nest(prefix, router);
524        self
525    }
526
527    /// Serve static files from a directory
528    ///
529    /// Maps a URL path prefix to a filesystem directory. Requests to paths under
530    /// the prefix will serve files from the corresponding location in the directory.
531    ///
532    /// # Arguments
533    ///
534    /// * `prefix` - URL path prefix (e.g., "/static", "/assets")
535    /// * `root` - Filesystem directory path
536    ///
537    /// # Features
538    ///
539    /// - Automatic MIME type detection
540    /// - ETag and Last-Modified headers for caching
541    /// - Index file serving for directories
542    /// - Path traversal prevention
543    ///
544    /// # Example
545    ///
546    /// ```rust,ignore
547    /// use rustapi_rs::prelude::*;
548    ///
549    /// RustApi::new()
550    ///     .serve_static("/assets", "./public")
551    ///     .serve_static("/uploads", "./uploads")
552    ///     .run("127.0.0.1:8080")
553    ///     .await
554    /// ```
555    pub fn serve_static(self, prefix: &str, root: impl Into<std::path::PathBuf>) -> Self {
556        self.serve_static_with_config(crate::static_files::StaticFileConfig::new(root, prefix))
557    }
558
559    /// Serve static files with custom configuration
560    ///
561    /// # Example
562    ///
563    /// ```rust,ignore
564    /// use rustapi_core::static_files::StaticFileConfig;
565    ///
566    /// let config = StaticFileConfig::new("./public", "/assets")
567    ///     .max_age(86400)  // Cache for 1 day
568    ///     .fallback("index.html");  // SPA fallback
569    ///
570    /// RustApi::new()
571    ///     .serve_static_with_config(config)
572    ///     .run("127.0.0.1:8080")
573    ///     .await
574    /// ```
575    pub fn serve_static_with_config(self, config: crate::static_files::StaticFileConfig) -> Self {
576        use crate::router::MethodRouter;
577        use std::collections::HashMap;
578
579        let prefix = config.prefix.clone();
580        let catch_all_path = format!("{}/*path", prefix.trim_end_matches('/'));
581
582        // Create the static file handler
583        let handler: crate::handler::BoxedHandler =
584            std::sync::Arc::new(move |req: crate::Request| {
585                let config = config.clone();
586                let path = req.uri().path().to_string();
587
588                Box::pin(async move {
589                    let relative_path = path.strip_prefix(&config.prefix).unwrap_or(&path);
590
591                    match crate::static_files::StaticFile::serve(relative_path, &config).await {
592                        Ok(response) => response,
593                        Err(err) => err.into_response(),
594                    }
595                })
596                    as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
597            });
598
599        let mut handlers = HashMap::new();
600        handlers.insert(http::Method::GET, handler);
601        let method_router = MethodRouter::from_boxed(handlers);
602
603        self.route(&catch_all_path, method_router)
604    }
605
606    /// Enable response compression
607    ///
608    /// Adds gzip/deflate compression for response bodies. The compression
609    /// is based on the client's Accept-Encoding header.
610    ///
611    /// # Example
612    ///
613    /// ```rust,ignore
614    /// use rustapi_rs::prelude::*;
615    ///
616    /// RustApi::new()
617    ///     .compression()
618    ///     .route("/", get(handler))
619    ///     .run("127.0.0.1:8080")
620    ///     .await
621    /// ```
622    #[cfg(feature = "compression")]
623    pub fn compression(self) -> Self {
624        self.layer(crate::middleware::CompressionLayer::new())
625    }
626
627    /// Enable response compression with custom configuration
628    ///
629    /// # Example
630    ///
631    /// ```rust,ignore
632    /// use rustapi_core::middleware::CompressionConfig;
633    ///
634    /// RustApi::new()
635    ///     .compression_with_config(
636    ///         CompressionConfig::new()
637    ///             .min_size(512)
638    ///             .level(9)
639    ///     )
640    ///     .route("/", get(handler))
641    /// ```
642    #[cfg(feature = "compression")]
643    pub fn compression_with_config(self, config: crate::middleware::CompressionConfig) -> Self {
644        self.layer(crate::middleware::CompressionLayer::with_config(config))
645    }
646
647    /// Enable Swagger UI documentation
648    ///
649    /// This adds two endpoints:
650    /// - `{path}` - Swagger UI interface
651    /// - `{path}/openapi.json` - OpenAPI JSON specification
652    ///
653    /// **Important:** Call `.docs()` AFTER registering all routes. The OpenAPI
654    /// specification is captured at the time `.docs()` is called, so routes
655    /// added afterwards will not appear in the documentation.
656    ///
657    /// # Example
658    ///
659    /// ```text
660    /// RustApi::new()
661    ///     .route("/users", get(list_users))     // Add routes first
662    ///     .route("/posts", get(list_posts))     // Add more routes
663    ///     .docs("/docs")  // Then enable docs - captures all routes above
664    ///     .run("127.0.0.1:8080")
665    ///     .await
666    /// ```
667    ///
668    /// For `RustApi::auto()`, routes are collected before `.docs()` is called,
669    /// so this is handled automatically.
670    #[cfg(feature = "swagger-ui")]
671    pub fn docs(self, path: &str) -> Self {
672        let title = self.openapi_spec.info.title.clone();
673        let version = self.openapi_spec.info.version.clone();
674        let description = self.openapi_spec.info.description.clone();
675
676        self.docs_with_info(path, &title, &version, description.as_deref())
677    }
678
679    /// Enable Swagger UI documentation with custom API info
680    ///
681    /// # Example
682    ///
683    /// ```rust,ignore
684    /// RustApi::new()
685    ///     .docs_with_info("/docs", "My API", "2.0.0", Some("API for managing users"))
686    /// ```
687    #[cfg(feature = "swagger-ui")]
688    pub fn docs_with_info(
689        mut self,
690        path: &str,
691        title: &str,
692        version: &str,
693        description: Option<&str>,
694    ) -> Self {
695        use crate::router::get;
696        // Update spec info
697        self.openapi_spec.info.title = title.to_string();
698        self.openapi_spec.info.version = version.to_string();
699        if let Some(desc) = description {
700            self.openapi_spec.info.description = Some(desc.to_string());
701        }
702
703        let path = path.trim_end_matches('/');
704        let openapi_path = format!("{}/openapi.json", path);
705
706        // Clone values for closures
707        let spec_json =
708            serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
709        let openapi_url = openapi_path.clone();
710
711        // Add OpenAPI JSON endpoint
712        let spec_handler = move || {
713            let json = spec_json.clone();
714            async move {
715                http::Response::builder()
716                    .status(http::StatusCode::OK)
717                    .header(http::header::CONTENT_TYPE, "application/json")
718                    .body(http_body_util::Full::new(bytes::Bytes::from(json)))
719                    .unwrap()
720            }
721        };
722
723        // Add Swagger UI endpoint
724        let docs_handler = move || {
725            let url = openapi_url.clone();
726            async move { rustapi_openapi::swagger_ui_html(&url) }
727        };
728
729        self.route(&openapi_path, get(spec_handler))
730            .route(path, get(docs_handler))
731    }
732
733    /// Enable Swagger UI documentation with Basic Auth protection
734    ///
735    /// When username and password are provided, the docs endpoint will require
736    /// Basic Authentication. This is useful for protecting API documentation
737    /// in production environments.
738    ///
739    /// # Example
740    ///
741    /// ```rust,ignore
742    /// RustApi::new()
743    ///     .route("/users", get(list_users))
744    ///     .docs_with_auth("/docs", "admin", "secret123")
745    ///     .run("127.0.0.1:8080")
746    ///     .await
747    /// ```
748    #[cfg(feature = "swagger-ui")]
749    pub fn docs_with_auth(self, path: &str, username: &str, password: &str) -> Self {
750        let title = self.openapi_spec.info.title.clone();
751        let version = self.openapi_spec.info.version.clone();
752        let description = self.openapi_spec.info.description.clone();
753
754        self.docs_with_auth_and_info(
755            path,
756            username,
757            password,
758            &title,
759            &version,
760            description.as_deref(),
761        )
762    }
763
764    /// Enable Swagger UI documentation with Basic Auth and custom API info
765    ///
766    /// # Example
767    ///
768    /// ```rust,ignore
769    /// RustApi::new()
770    ///     .docs_with_auth_and_info(
771    ///         "/docs",
772    ///         "admin",
773    ///         "secret",
774    ///         "My API",
775    ///         "2.0.0",
776    ///         Some("Protected API documentation")
777    ///     )
778    /// ```
779    #[cfg(feature = "swagger-ui")]
780    pub fn docs_with_auth_and_info(
781        mut self,
782        path: &str,
783        username: &str,
784        password: &str,
785        title: &str,
786        version: &str,
787        description: Option<&str>,
788    ) -> Self {
789        use crate::router::MethodRouter;
790        use base64::{engine::general_purpose::STANDARD, Engine};
791        use std::collections::HashMap;
792
793        // Update spec info
794        self.openapi_spec.info.title = title.to_string();
795        self.openapi_spec.info.version = version.to_string();
796        if let Some(desc) = description {
797            self.openapi_spec.info.description = Some(desc.to_string());
798        }
799
800        let path = path.trim_end_matches('/');
801        let openapi_path = format!("{}/openapi.json", path);
802
803        // Create expected auth header value
804        let credentials = format!("{}:{}", username, password);
805        let encoded = STANDARD.encode(credentials.as_bytes());
806        let expected_auth = format!("Basic {}", encoded);
807
808        // Clone values for closures
809        let spec_json =
810            serde_json::to_string_pretty(&self.openapi_spec.to_json()).unwrap_or_default();
811        let openapi_url = openapi_path.clone();
812        let expected_auth_spec = expected_auth.clone();
813        let expected_auth_docs = expected_auth;
814
815        // Create spec handler with auth check
816        let spec_handler: crate::handler::BoxedHandler =
817            std::sync::Arc::new(move |req: crate::Request| {
818                let json = spec_json.clone();
819                let expected = expected_auth_spec.clone();
820                Box::pin(async move {
821                    if !check_basic_auth(&req, &expected) {
822                        return unauthorized_response();
823                    }
824                    http::Response::builder()
825                        .status(http::StatusCode::OK)
826                        .header(http::header::CONTENT_TYPE, "application/json")
827                        .body(http_body_util::Full::new(bytes::Bytes::from(json)))
828                        .unwrap()
829                })
830                    as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
831            });
832
833        // Create docs handler with auth check
834        let docs_handler: crate::handler::BoxedHandler =
835            std::sync::Arc::new(move |req: crate::Request| {
836                let url = openapi_url.clone();
837                let expected = expected_auth_docs.clone();
838                Box::pin(async move {
839                    if !check_basic_auth(&req, &expected) {
840                        return unauthorized_response();
841                    }
842                    rustapi_openapi::swagger_ui_html(&url)
843                })
844                    as std::pin::Pin<Box<dyn std::future::Future<Output = crate::Response> + Send>>
845            });
846
847        // Create method routers with boxed handlers
848        let mut spec_handlers = HashMap::new();
849        spec_handlers.insert(http::Method::GET, spec_handler);
850        let spec_router = MethodRouter::from_boxed(spec_handlers);
851
852        let mut docs_handlers = HashMap::new();
853        docs_handlers.insert(http::Method::GET, docs_handler);
854        let docs_router = MethodRouter::from_boxed(docs_handlers);
855
856        self.route(&openapi_path, spec_router)
857            .route(path, docs_router)
858    }
859
860    /// Run the server
861    ///
862    /// # Example
863    ///
864    /// ```rust,ignore
865    /// RustApi::new()
866    ///     .route("/", get(hello))
867    ///     .run("127.0.0.1:8080")
868    ///     .await
869    /// ```
870    pub async fn run(mut self, addr: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
871        // Apply body limit layer if configured (should be first in the chain)
872        if let Some(limit) = self.body_limit {
873            // Prepend body limit layer so it's the first to process requests
874            self.layers.prepend(Box::new(BodyLimitLayer::new(limit)));
875        }
876
877        let server = Server::new(self.router, self.layers, self.interceptors);
878        server.run(addr).await
879    }
880
881    /// Get the inner router (for testing or advanced usage)
882    pub fn into_router(self) -> Router {
883        self.router
884    }
885
886    /// Get the layer stack (for testing)
887    pub fn layers(&self) -> &LayerStack {
888        &self.layers
889    }
890
891    /// Get the interceptor chain (for testing)
892    pub fn interceptors(&self) -> &InterceptorChain {
893        &self.interceptors
894    }
895}
896
897fn add_path_params_to_operation(path: &str, op: &mut rustapi_openapi::Operation) {
898    let mut params: Vec<String> = Vec::new();
899    let mut in_brace = false;
900    let mut current = String::new();
901
902    for ch in path.chars() {
903        match ch {
904            '{' => {
905                in_brace = true;
906                current.clear();
907            }
908            '}' => {
909                if in_brace {
910                    in_brace = false;
911                    if !current.is_empty() {
912                        params.push(current.clone());
913                    }
914                }
915            }
916            _ => {
917                if in_brace {
918                    current.push(ch);
919                }
920            }
921        }
922    }
923
924    if params.is_empty() {
925        return;
926    }
927
928    let op_params = op.parameters.get_or_insert_with(Vec::new);
929
930    for name in params {
931        let already = op_params
932            .iter()
933            .any(|p| p.location == "path" && p.name == name);
934        if already {
935            continue;
936        }
937
938        // Infer schema type based on common naming patterns
939        let schema = infer_path_param_schema(&name);
940
941        op_params.push(rustapi_openapi::Parameter {
942            name,
943            location: "path".to_string(),
944            required: true,
945            description: None,
946            schema,
947        });
948    }
949}
950
951/// Infer the OpenAPI schema type for a path parameter based on naming conventions.
952///
953/// Common patterns:
954/// - `*_id`, `*Id`, `id` → integer (but NOT *uuid)
955/// - `*_count`, `*_num`, `page`, `limit`, `offset` → integer  
956/// - `*_uuid`, `uuid` → string with uuid format
957/// - `year`, `month`, `day` → integer
958/// - Everything else → string
959fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef {
960    let lower = name.to_lowercase();
961
962    // UUID patterns (check first to avoid false positive from "id" suffix)
963    let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid");
964
965    if is_uuid {
966        return rustapi_openapi::SchemaRef::Inline(serde_json::json!({
967            "type": "string",
968            "format": "uuid"
969        }));
970    }
971
972    // Integer patterns
973    let is_integer = lower == "id"
974        || lower.ends_with("_id")
975        || (lower.ends_with("id") && lower.len() > 2) // e.g., "userId", but not "uuid"
976        || lower == "page"
977        || lower == "limit"
978        || lower == "offset"
979        || lower == "count"
980        || lower.ends_with("_count")
981        || lower.ends_with("_num")
982        || lower == "year"
983        || lower == "month"
984        || lower == "day"
985        || lower == "index"
986        || lower == "position";
987
988    if is_integer {
989        rustapi_openapi::SchemaRef::Inline(serde_json::json!({
990            "type": "integer",
991            "format": "int64"
992        }))
993    } else {
994        rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" }))
995    }
996}
997
998/// Normalize a prefix for OpenAPI paths.
999///
1000/// Ensures the prefix:
1001/// - Starts with exactly one leading slash
1002/// - Has no trailing slash (unless it's just "/")
1003/// - Has no double slashes
1004fn normalize_prefix_for_openapi(prefix: &str) -> String {
1005    // Handle empty string
1006    if prefix.is_empty() {
1007        return "/".to_string();
1008    }
1009
1010    // Split by slashes and filter out empty segments (handles multiple slashes)
1011    let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect();
1012
1013    // If no segments after filtering, return root
1014    if segments.is_empty() {
1015        return "/".to_string();
1016    }
1017
1018    // Build the normalized prefix with leading slash
1019    let mut result = String::with_capacity(prefix.len() + 1);
1020    for segment in segments {
1021        result.push('/');
1022        result.push_str(segment);
1023    }
1024
1025    result
1026}
1027
1028impl Default for RustApi {
1029    fn default() -> Self {
1030        Self::new()
1031    }
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::RustApi;
1037    use crate::extract::{FromRequestParts, State};
1038    use crate::path_params::PathParams;
1039    use crate::request::Request;
1040    use crate::router::{get, post, Router};
1041    use bytes::Bytes;
1042    use http::Method;
1043    use proptest::prelude::*;
1044
1045    #[test]
1046    fn state_is_available_via_extractor() {
1047        let app = RustApi::new().state(123u32);
1048        let router = app.into_router();
1049
1050        let req = http::Request::builder()
1051            .method(Method::GET)
1052            .uri("/test")
1053            .body(())
1054            .unwrap();
1055        let (parts, _) = req.into_parts();
1056
1057        let request = Request::new(
1058            parts,
1059            crate::request::BodyVariant::Buffered(Bytes::new()),
1060            router.state_ref(),
1061            PathParams::new(),
1062        );
1063        let State(value) = State::<u32>::from_request_parts(&request).unwrap();
1064        assert_eq!(value, 123u32);
1065    }
1066
1067    #[test]
1068    fn test_path_param_type_inference_integer() {
1069        use super::infer_path_param_schema;
1070
1071        // Test common integer patterns
1072        let int_params = [
1073            "id",
1074            "user_id",
1075            "userId",
1076            "postId",
1077            "page",
1078            "limit",
1079            "offset",
1080            "count",
1081            "item_count",
1082            "year",
1083            "month",
1084            "day",
1085            "index",
1086            "position",
1087        ];
1088
1089        for name in int_params {
1090            let schema = infer_path_param_schema(name);
1091            match schema {
1092                rustapi_openapi::SchemaRef::Inline(v) => {
1093                    assert_eq!(
1094                        v.get("type").and_then(|v| v.as_str()),
1095                        Some("integer"),
1096                        "Expected '{}' to be inferred as integer",
1097                        name
1098                    );
1099                }
1100                _ => panic!("Expected inline schema for '{}'", name),
1101            }
1102        }
1103    }
1104
1105    #[test]
1106    fn test_path_param_type_inference_uuid() {
1107        use super::infer_path_param_schema;
1108
1109        // Test UUID patterns
1110        let uuid_params = ["uuid", "user_uuid", "sessionUuid"];
1111
1112        for name in uuid_params {
1113            let schema = infer_path_param_schema(name);
1114            match schema {
1115                rustapi_openapi::SchemaRef::Inline(v) => {
1116                    assert_eq!(
1117                        v.get("type").and_then(|v| v.as_str()),
1118                        Some("string"),
1119                        "Expected '{}' to be inferred as string",
1120                        name
1121                    );
1122                    assert_eq!(
1123                        v.get("format").and_then(|v| v.as_str()),
1124                        Some("uuid"),
1125                        "Expected '{}' to have uuid format",
1126                        name
1127                    );
1128                }
1129                _ => panic!("Expected inline schema for '{}'", name),
1130            }
1131        }
1132    }
1133
1134    #[test]
1135    fn test_path_param_type_inference_string() {
1136        use super::infer_path_param_schema;
1137
1138        // Test string (default) patterns
1139        let string_params = ["name", "slug", "code", "token", "username"];
1140
1141        for name in string_params {
1142            let schema = infer_path_param_schema(name);
1143            match schema {
1144                rustapi_openapi::SchemaRef::Inline(v) => {
1145                    assert_eq!(
1146                        v.get("type").and_then(|v| v.as_str()),
1147                        Some("string"),
1148                        "Expected '{}' to be inferred as string",
1149                        name
1150                    );
1151                    assert!(
1152                        v.get("format").is_none()
1153                            || v.get("format").and_then(|v| v.as_str()) != Some("uuid"),
1154                        "Expected '{}' to NOT have uuid format",
1155                        name
1156                    );
1157                }
1158                _ => panic!("Expected inline schema for '{}'", name),
1159            }
1160        }
1161    }
1162
1163    // **Feature: router-nesting, Property 11: OpenAPI Integration**
1164    //
1165    // For any nested routes with OpenAPI operations, the operations should appear
1166    // in the parent's OpenAPI spec with prefixed paths and preserved metadata.
1167    //
1168    // **Validates: Requirements 4.1, 4.2**
1169    proptest! {
1170        #![proptest_config(ProptestConfig::with_cases(100))]
1171
1172        /// Property: Nested routes appear in OpenAPI spec with prefixed paths
1173        ///
1174        /// For any router with routes nested under a prefix, all routes should
1175        /// appear in the OpenAPI spec with the prefix prepended to their paths.
1176        #[test]
1177        fn prop_nested_routes_in_openapi_spec(
1178            // Generate prefix segments
1179            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1180            // Generate route path segments
1181            route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1182            has_param in any::<bool>(),
1183        ) {
1184            async fn handler() -> &'static str { "handler" }
1185
1186            // Build the prefix
1187            let prefix = format!("/{}", prefix_segments.join("/"));
1188
1189            // Build the route path
1190            let mut route_path = format!("/{}", route_segments.join("/"));
1191            if has_param {
1192                route_path.push_str("/{id}");
1193            }
1194
1195            // Create nested router and nest it through RustApi
1196            let nested_router = Router::new().route(&route_path, get(handler));
1197            let app = RustApi::new().nest(&prefix, nested_router);
1198
1199            // Build expected prefixed path for OpenAPI (uses {param} format)
1200            let expected_openapi_path = format!("{}{}", prefix, route_path);
1201
1202            // Get the OpenAPI spec
1203            let spec = app.openapi_spec();
1204
1205            // Property: The prefixed route should exist in OpenAPI paths
1206            prop_assert!(
1207                spec.paths.contains_key(&expected_openapi_path),
1208                "Expected OpenAPI path '{}' not found. Available paths: {:?}",
1209                expected_openapi_path,
1210                spec.paths.keys().collect::<Vec<_>>()
1211            );
1212
1213            // Property: The path item should have a GET operation
1214            let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1215            prop_assert!(
1216                path_item.get.is_some(),
1217                "GET operation should exist for path '{}'",
1218                expected_openapi_path
1219            );
1220        }
1221
1222        /// Property: Multiple HTTP methods are preserved in OpenAPI spec after nesting
1223        ///
1224        /// For any router with routes having multiple HTTP methods, nesting should
1225        /// preserve all method operations in the OpenAPI spec.
1226        #[test]
1227        fn prop_multiple_methods_preserved_in_openapi(
1228            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1229            route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1230        ) {
1231            async fn get_handler() -> &'static str { "get" }
1232            async fn post_handler() -> &'static str { "post" }
1233
1234            // Build the prefix and route path
1235            let prefix = format!("/{}", prefix_segments.join("/"));
1236            let route_path = format!("/{}", route_segments.join("/"));
1237
1238            // Create nested router with both GET and POST using separate routes
1239            // Since MethodRouter doesn't have chaining methods, we create two routes
1240            let get_route_path = format!("{}/get", route_path);
1241            let post_route_path = format!("{}/post", route_path);
1242            let nested_router = Router::new()
1243                .route(&get_route_path, get(get_handler))
1244                .route(&post_route_path, post(post_handler));
1245            let app = RustApi::new().nest(&prefix, nested_router);
1246
1247            // Build expected prefixed paths for OpenAPI
1248            let expected_get_path = format!("{}{}", prefix, get_route_path);
1249            let expected_post_path = format!("{}{}", prefix, post_route_path);
1250
1251            // Get the OpenAPI spec
1252            let spec = app.openapi_spec();
1253
1254            // Property: Both paths should exist
1255            prop_assert!(
1256                spec.paths.contains_key(&expected_get_path),
1257                "Expected OpenAPI path '{}' not found",
1258                expected_get_path
1259            );
1260            prop_assert!(
1261                spec.paths.contains_key(&expected_post_path),
1262                "Expected OpenAPI path '{}' not found",
1263                expected_post_path
1264            );
1265
1266            // Property: GET operation should exist on get path
1267            let get_path_item = spec.paths.get(&expected_get_path).unwrap();
1268            prop_assert!(
1269                get_path_item.get.is_some(),
1270                "GET operation should exist for path '{}'",
1271                expected_get_path
1272            );
1273
1274            // Property: POST operation should exist on post path
1275            let post_path_item = spec.paths.get(&expected_post_path).unwrap();
1276            prop_assert!(
1277                post_path_item.post.is_some(),
1278                "POST operation should exist for path '{}'",
1279                expected_post_path
1280            );
1281        }
1282
1283        /// Property: Path parameters are added to OpenAPI operations after nesting
1284        ///
1285        /// For any nested route with path parameters, the OpenAPI operation should
1286        /// include the path parameters.
1287        #[test]
1288        fn prop_path_params_in_openapi_after_nesting(
1289            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1290            param_name in "[a-z][a-z0-9]{0,5}",
1291        ) {
1292            async fn handler() -> &'static str { "handler" }
1293
1294            // Build the prefix and route path with parameter
1295            let prefix = format!("/{}", prefix_segments.join("/"));
1296            let route_path = format!("/{{{}}}", param_name);
1297
1298            // Create nested router
1299            let nested_router = Router::new().route(&route_path, get(handler));
1300            let app = RustApi::new().nest(&prefix, nested_router);
1301
1302            // Build expected prefixed path for OpenAPI
1303            let expected_openapi_path = format!("{}{}", prefix, route_path);
1304
1305            // Get the OpenAPI spec
1306            let spec = app.openapi_spec();
1307
1308            // Property: The path should exist
1309            prop_assert!(
1310                spec.paths.contains_key(&expected_openapi_path),
1311                "Expected OpenAPI path '{}' not found",
1312                expected_openapi_path
1313            );
1314
1315            // Property: The GET operation should have the path parameter
1316            let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1317            let get_op = path_item.get.as_ref().unwrap();
1318
1319            prop_assert!(
1320                get_op.parameters.is_some(),
1321                "Operation should have parameters for path '{}'",
1322                expected_openapi_path
1323            );
1324
1325            let params = get_op.parameters.as_ref().unwrap();
1326            let has_param = params.iter().any(|p| p.name == param_name && p.location == "path");
1327            prop_assert!(
1328                has_param,
1329                "Path parameter '{}' should exist in operation parameters. Found: {:?}",
1330                param_name,
1331                params.iter().map(|p| &p.name).collect::<Vec<_>>()
1332            );
1333        }
1334    }
1335
1336    // **Feature: router-nesting, Property 13: RustApi Integration**
1337    //
1338    // For any router nested through `RustApi::new().nest()`, the behavior should be
1339    // identical to nesting through `Router::new().nest()`, and routes should appear
1340    // in the OpenAPI spec.
1341    //
1342    // **Validates: Requirements 6.1, 6.2**
1343    proptest! {
1344        #![proptest_config(ProptestConfig::with_cases(100))]
1345
1346        /// Property: RustApi::nest delegates to Router::nest and produces identical route registration
1347        ///
1348        /// For any router with routes nested under a prefix, nesting through RustApi
1349        /// should produce the same route registration as nesting through Router directly.
1350        #[test]
1351        fn prop_rustapi_nest_delegates_to_router_nest(
1352            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1353            route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1354            has_param in any::<bool>(),
1355        ) {
1356            async fn handler() -> &'static str { "handler" }
1357
1358            // Build the prefix
1359            let prefix = format!("/{}", prefix_segments.join("/"));
1360
1361            // Build the route path
1362            let mut route_path = format!("/{}", route_segments.join("/"));
1363            if has_param {
1364                route_path.push_str("/{id}");
1365            }
1366
1367            // Create nested router
1368            let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1369            let nested_router_for_router = Router::new().route(&route_path, get(handler));
1370
1371            // Nest through RustApi
1372            let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1373            let rustapi_router = rustapi_app.into_router();
1374
1375            // Nest through Router directly
1376            let router_app = Router::new().nest(&prefix, nested_router_for_router);
1377
1378            // Property: Both should have the same registered routes
1379            let rustapi_routes = rustapi_router.registered_routes();
1380            let router_routes = router_app.registered_routes();
1381
1382            prop_assert_eq!(
1383                rustapi_routes.len(),
1384                router_routes.len(),
1385                "RustApi and Router should have same number of routes"
1386            );
1387
1388            // Property: All routes from Router should exist in RustApi
1389            for (path, info) in router_routes {
1390                prop_assert!(
1391                    rustapi_routes.contains_key(path),
1392                    "Route '{}' from Router should exist in RustApi routes",
1393                    path
1394                );
1395
1396                let rustapi_info = rustapi_routes.get(path).unwrap();
1397                prop_assert_eq!(
1398                    &info.path, &rustapi_info.path,
1399                    "Display paths should match for route '{}'",
1400                    path
1401                );
1402                prop_assert_eq!(
1403                    info.methods.len(), rustapi_info.methods.len(),
1404                    "Method count should match for route '{}'",
1405                    path
1406                );
1407            }
1408        }
1409
1410        /// Property: RustApi::nest includes nested routes in OpenAPI spec
1411        ///
1412        /// For any router with routes nested through RustApi, all routes should
1413        /// appear in the OpenAPI specification with prefixed paths.
1414        #[test]
1415        fn prop_rustapi_nest_includes_routes_in_openapi(
1416            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1417            route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3),
1418            has_param in any::<bool>(),
1419        ) {
1420            async fn handler() -> &'static str { "handler" }
1421
1422            // Build the prefix
1423            let prefix = format!("/{}", prefix_segments.join("/"));
1424
1425            // Build the route path
1426            let mut route_path = format!("/{}", route_segments.join("/"));
1427            if has_param {
1428                route_path.push_str("/{id}");
1429            }
1430
1431            // Create nested router and nest through RustApi
1432            let nested_router = Router::new().route(&route_path, get(handler));
1433            let app = RustApi::new().nest(&prefix, nested_router);
1434
1435            // Build expected prefixed path for OpenAPI
1436            let expected_openapi_path = format!("{}{}", prefix, route_path);
1437
1438            // Get the OpenAPI spec
1439            let spec = app.openapi_spec();
1440
1441            // Property: The prefixed route should exist in OpenAPI paths
1442            prop_assert!(
1443                spec.paths.contains_key(&expected_openapi_path),
1444                "Expected OpenAPI path '{}' not found. Available paths: {:?}",
1445                expected_openapi_path,
1446                spec.paths.keys().collect::<Vec<_>>()
1447            );
1448
1449            // Property: The path item should have a GET operation
1450            let path_item = spec.paths.get(&expected_openapi_path).unwrap();
1451            prop_assert!(
1452                path_item.get.is_some(),
1453                "GET operation should exist for path '{}'",
1454                expected_openapi_path
1455            );
1456        }
1457
1458        /// Property: RustApi::nest route matching is identical to Router::nest
1459        ///
1460        /// For any nested route, matching through RustApi should produce the same
1461        /// result as matching through Router directly.
1462        #[test]
1463        fn prop_rustapi_nest_route_matching_identical(
1464            prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1465            route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2),
1466            param_value in "[a-z0-9]{1,10}",
1467        ) {
1468            use crate::router::RouteMatch;
1469
1470            async fn handler() -> &'static str { "handler" }
1471
1472            // Build the prefix and route path with parameter
1473            let prefix = format!("/{}", prefix_segments.join("/"));
1474            let route_path = format!("/{}/{{id}}", route_segments.join("/"));
1475
1476            // Create nested routers
1477            let nested_router_for_rustapi = Router::new().route(&route_path, get(handler));
1478            let nested_router_for_router = Router::new().route(&route_path, get(handler));
1479
1480            // Nest through both RustApi and Router
1481            let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi);
1482            let rustapi_router = rustapi_app.into_router();
1483            let router_app = Router::new().nest(&prefix, nested_router_for_router);
1484
1485            // Build the full path to match
1486            let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value);
1487
1488            // Match through both
1489            let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET);
1490            let router_match = router_app.match_route(&full_path, &Method::GET);
1491
1492            // Property: Both should return Found with same parameters
1493            match (rustapi_match, router_match) {
1494                (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => {
1495                    prop_assert_eq!(
1496                        rustapi_params.len(),
1497                        router_params.len(),
1498                        "Parameter count should match"
1499                    );
1500                    for (key, value) in &router_params {
1501                        prop_assert!(
1502                            rustapi_params.contains_key(key),
1503                            "RustApi should have parameter '{}'",
1504                            key
1505                        );
1506                        prop_assert_eq!(
1507                            rustapi_params.get(key).unwrap(),
1508                            value,
1509                            "Parameter '{}' value should match",
1510                            key
1511                        );
1512                    }
1513                }
1514                (rustapi_result, router_result) => {
1515                    prop_assert!(
1516                        false,
1517                        "Both should return Found, but RustApi returned {:?} and Router returned {:?}",
1518                        match rustapi_result {
1519                            RouteMatch::Found { .. } => "Found",
1520                            RouteMatch::NotFound => "NotFound",
1521                            RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
1522                        },
1523                        match router_result {
1524                            RouteMatch::Found { .. } => "Found",
1525                            RouteMatch::NotFound => "NotFound",
1526                            RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed",
1527                        }
1528                    );
1529                }
1530            }
1531        }
1532    }
1533
1534    /// Unit test: Verify OpenAPI operations are propagated during nesting
1535    #[test]
1536    fn test_openapi_operations_propagated_during_nesting() {
1537        async fn list_users() -> &'static str {
1538            "list users"
1539        }
1540        async fn get_user() -> &'static str {
1541            "get user"
1542        }
1543        async fn create_user() -> &'static str {
1544            "create user"
1545        }
1546
1547        // Create nested router with multiple routes
1548        // Note: We use separate routes since MethodRouter doesn't support chaining
1549        let users_router = Router::new()
1550            .route("/", get(list_users))
1551            .route("/create", post(create_user))
1552            .route("/{id}", get(get_user));
1553
1554        // Nest under /api/v1/users
1555        let app = RustApi::new().nest("/api/v1/users", users_router);
1556
1557        let spec = app.openapi_spec();
1558
1559        // Verify /api/v1/users path exists with GET
1560        assert!(
1561            spec.paths.contains_key("/api/v1/users"),
1562            "Should have /api/v1/users path"
1563        );
1564        let users_path = spec.paths.get("/api/v1/users").unwrap();
1565        assert!(users_path.get.is_some(), "Should have GET operation");
1566
1567        // Verify /api/v1/users/create path exists with POST
1568        assert!(
1569            spec.paths.contains_key("/api/v1/users/create"),
1570            "Should have /api/v1/users/create path"
1571        );
1572        let create_path = spec.paths.get("/api/v1/users/create").unwrap();
1573        assert!(create_path.post.is_some(), "Should have POST operation");
1574
1575        // Verify /api/v1/users/{id} path exists with GET
1576        assert!(
1577            spec.paths.contains_key("/api/v1/users/{id}"),
1578            "Should have /api/v1/users/{{id}} path"
1579        );
1580        let user_path = spec.paths.get("/api/v1/users/{id}").unwrap();
1581        assert!(
1582            user_path.get.is_some(),
1583            "Should have GET operation for user by id"
1584        );
1585
1586        // Verify path parameter is added
1587        let get_user_op = user_path.get.as_ref().unwrap();
1588        assert!(get_user_op.parameters.is_some(), "Should have parameters");
1589        let params = get_user_op.parameters.as_ref().unwrap();
1590        assert!(
1591            params
1592                .iter()
1593                .any(|p| p.name == "id" && p.location == "path"),
1594            "Should have 'id' path parameter"
1595        );
1596    }
1597
1598    /// Unit test: Verify nested routes don't appear without nesting
1599    #[test]
1600    fn test_openapi_spec_empty_without_routes() {
1601        let app = RustApi::new();
1602        let spec = app.openapi_spec();
1603
1604        // Should have no paths (except potentially default ones)
1605        assert!(
1606            spec.paths.is_empty(),
1607            "OpenAPI spec should have no paths without routes"
1608        );
1609    }
1610
1611    /// Unit test: Verify RustApi::nest delegates correctly to Router::nest
1612    ///
1613    /// **Feature: router-nesting, Property 13: RustApi Integration**
1614    /// **Validates: Requirements 6.1, 6.2**
1615    #[test]
1616    fn test_rustapi_nest_delegates_to_router_nest() {
1617        use crate::router::RouteMatch;
1618
1619        async fn list_users() -> &'static str {
1620            "list users"
1621        }
1622        async fn get_user() -> &'static str {
1623            "get user"
1624        }
1625        async fn create_user() -> &'static str {
1626            "create user"
1627        }
1628
1629        // Create nested router with multiple routes
1630        let users_router = Router::new()
1631            .route("/", get(list_users))
1632            .route("/create", post(create_user))
1633            .route("/{id}", get(get_user));
1634
1635        // Nest through RustApi
1636        let app = RustApi::new().nest("/api/v1/users", users_router);
1637        let router = app.into_router();
1638
1639        // Verify routes are registered correctly
1640        let routes = router.registered_routes();
1641        assert_eq!(routes.len(), 3, "Should have 3 routes registered");
1642
1643        // Verify route paths
1644        assert!(
1645            routes.contains_key("/api/v1/users"),
1646            "Should have /api/v1/users route"
1647        );
1648        assert!(
1649            routes.contains_key("/api/v1/users/create"),
1650            "Should have /api/v1/users/create route"
1651        );
1652        assert!(
1653            routes.contains_key("/api/v1/users/:id"),
1654            "Should have /api/v1/users/:id route"
1655        );
1656
1657        // Verify route matching works
1658        match router.match_route("/api/v1/users", &Method::GET) {
1659            RouteMatch::Found { params, .. } => {
1660                assert!(params.is_empty(), "Root route should have no params");
1661            }
1662            _ => panic!("GET /api/v1/users should be found"),
1663        }
1664
1665        match router.match_route("/api/v1/users/create", &Method::POST) {
1666            RouteMatch::Found { params, .. } => {
1667                assert!(params.is_empty(), "Create route should have no params");
1668            }
1669            _ => panic!("POST /api/v1/users/create should be found"),
1670        }
1671
1672        match router.match_route("/api/v1/users/123", &Method::GET) {
1673            RouteMatch::Found { params, .. } => {
1674                assert_eq!(
1675                    params.get("id"),
1676                    Some(&"123".to_string()),
1677                    "Should extract id param"
1678                );
1679            }
1680            _ => panic!("GET /api/v1/users/123 should be found"),
1681        }
1682
1683        // Verify method not allowed
1684        match router.match_route("/api/v1/users", &Method::DELETE) {
1685            RouteMatch::MethodNotAllowed { allowed } => {
1686                assert!(allowed.contains(&Method::GET), "Should allow GET");
1687            }
1688            _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"),
1689        }
1690    }
1691
1692    /// Unit test: Verify RustApi::nest includes routes in OpenAPI spec
1693    ///
1694    /// **Feature: router-nesting, Property 13: RustApi Integration**
1695    /// **Validates: Requirements 6.1, 6.2**
1696    #[test]
1697    fn test_rustapi_nest_includes_routes_in_openapi_spec() {
1698        async fn list_items() -> &'static str {
1699            "list items"
1700        }
1701        async fn get_item() -> &'static str {
1702            "get item"
1703        }
1704
1705        // Create nested router
1706        let items_router = Router::new()
1707            .route("/", get(list_items))
1708            .route("/{item_id}", get(get_item));
1709
1710        // Nest through RustApi
1711        let app = RustApi::new().nest("/api/items", items_router);
1712
1713        // Verify OpenAPI spec
1714        let spec = app.openapi_spec();
1715
1716        // Verify paths exist
1717        assert!(
1718            spec.paths.contains_key("/api/items"),
1719            "Should have /api/items in OpenAPI"
1720        );
1721        assert!(
1722            spec.paths.contains_key("/api/items/{item_id}"),
1723            "Should have /api/items/{{item_id}} in OpenAPI"
1724        );
1725
1726        // Verify operations
1727        let list_path = spec.paths.get("/api/items").unwrap();
1728        assert!(
1729            list_path.get.is_some(),
1730            "Should have GET operation for /api/items"
1731        );
1732
1733        let get_path = spec.paths.get("/api/items/{item_id}").unwrap();
1734        assert!(
1735            get_path.get.is_some(),
1736            "Should have GET operation for /api/items/{{item_id}}"
1737        );
1738
1739        // Verify path parameter is added
1740        let get_op = get_path.get.as_ref().unwrap();
1741        assert!(get_op.parameters.is_some(), "Should have parameters");
1742        let params = get_op.parameters.as_ref().unwrap();
1743        assert!(
1744            params
1745                .iter()
1746                .any(|p| p.name == "item_id" && p.location == "path"),
1747            "Should have 'item_id' path parameter"
1748        );
1749    }
1750}
1751
1752/// Check Basic Auth header against expected credentials
1753#[cfg(feature = "swagger-ui")]
1754fn check_basic_auth(req: &crate::Request, expected: &str) -> bool {
1755    req.headers()
1756        .get(http::header::AUTHORIZATION)
1757        .and_then(|v| v.to_str().ok())
1758        .map(|auth| auth == expected)
1759        .unwrap_or(false)
1760}
1761
1762/// Create 401 Unauthorized response with WWW-Authenticate header
1763#[cfg(feature = "swagger-ui")]
1764fn unauthorized_response() -> crate::Response {
1765    http::Response::builder()
1766        .status(http::StatusCode::UNAUTHORIZED)
1767        .header(
1768            http::header::WWW_AUTHENTICATE,
1769            "Basic realm=\"API Documentation\"",
1770        )
1771        .header(http::header::CONTENT_TYPE, "text/plain")
1772        .body(http_body_util::Full::new(bytes::Bytes::from(
1773            "Unauthorized",
1774        )))
1775        .unwrap()
1776}
1777
1778/// Configuration builder for RustAPI with auto-routes
1779pub struct RustApiConfig {
1780    docs_path: Option<String>,
1781    docs_enabled: bool,
1782    api_title: String,
1783    api_version: String,
1784    api_description: Option<String>,
1785    body_limit: Option<usize>,
1786    layers: LayerStack,
1787}
1788
1789impl Default for RustApiConfig {
1790    fn default() -> Self {
1791        Self::new()
1792    }
1793}
1794
1795impl RustApiConfig {
1796    pub fn new() -> Self {
1797        Self {
1798            docs_path: Some("/docs".to_string()),
1799            docs_enabled: true,
1800            api_title: "RustAPI".to_string(),
1801            api_version: "1.0.0".to_string(),
1802            api_description: None,
1803            body_limit: None,
1804            layers: LayerStack::new(),
1805        }
1806    }
1807
1808    /// Set the docs path (default: "/docs")
1809    pub fn docs_path(mut self, path: impl Into<String>) -> Self {
1810        self.docs_path = Some(path.into());
1811        self
1812    }
1813
1814    /// Enable or disable docs (default: true)
1815    pub fn docs_enabled(mut self, enabled: bool) -> Self {
1816        self.docs_enabled = enabled;
1817        self
1818    }
1819
1820    /// Set OpenAPI info
1821    pub fn openapi_info(
1822        mut self,
1823        title: impl Into<String>,
1824        version: impl Into<String>,
1825        description: Option<impl Into<String>>,
1826    ) -> Self {
1827        self.api_title = title.into();
1828        self.api_version = version.into();
1829        self.api_description = description.map(|d| d.into());
1830        self
1831    }
1832
1833    /// Set body size limit
1834    pub fn body_limit(mut self, limit: usize) -> Self {
1835        self.body_limit = Some(limit);
1836        self
1837    }
1838
1839    /// Add a middleware layer
1840    pub fn layer<L>(mut self, layer: L) -> Self
1841    where
1842        L: MiddlewareLayer,
1843    {
1844        self.layers.push(Box::new(layer));
1845        self
1846    }
1847
1848    /// Build the RustApi instance
1849    pub fn build(self) -> RustApi {
1850        let mut app = RustApi::new().mount_auto_routes_grouped();
1851
1852        // Apply configuration
1853        if let Some(limit) = self.body_limit {
1854            app = app.body_limit(limit);
1855        }
1856
1857        app = app.openapi_info(
1858            &self.api_title,
1859            &self.api_version,
1860            self.api_description.as_deref(),
1861        );
1862
1863        #[cfg(feature = "swagger-ui")]
1864        if self.docs_enabled {
1865            if let Some(path) = self.docs_path {
1866                app = app.docs(&path);
1867            }
1868        }
1869
1870        // Apply layers
1871        // Note: layers are applied in reverse order in RustApi::layer logic (pushing to vec)
1872        app.layers.extend(self.layers);
1873
1874        app
1875    }
1876
1877    /// Build and run the server
1878    pub async fn run(
1879        self,
1880        addr: impl AsRef<str>,
1881    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1882        self.build().run(addr.as_ref()).await
1883    }
1884}