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