From 080bab86733b2d58dbe6d4a95910851c04b26caa Mon Sep 17 00:00:00 2001 From: Tuntii Date: Tue, 23 Jun 2026 03:39:38 +0300 Subject: [PATCH 1/2] fix(core): classifier gaps for issue #201 builder split Extract serve_pipeline from run.rs so all run* entrypoints share one orchestration path. Add file-scope --lib run_with_shutdown tests that mirror the pre-existing integration harness. Move extract tests to tests/extract.rs and fix non-shutdown HTTP/3/dual-stack entrypoints so on_shutdown hooks are not taken and dropped on pending delegates. --- crates/rustapi-core/src/app/mod.rs | 1 + crates/rustapi-core/src/app/run.rs | 349 +++---------- crates/rustapi-core/src/app/serve_pipeline.rs | 201 +++++++ crates/rustapi-core/src/app/tests.rs | 490 +++++++++++++++++- crates/rustapi-core/src/extract.rs | 4 - .../extract_tests.rs => tests/extract.rs} | 25 +- 6 files changed, 745 insertions(+), 325 deletions(-) create mode 100644 crates/rustapi-core/src/app/serve_pipeline.rs rename crates/rustapi-core/{src/extract_tests.rs => tests/extract.rs} (97%) diff --git a/crates/rustapi-core/src/app/mod.rs b/crates/rustapi-core/src/app/mod.rs index e86d7ec..6190b40 100644 --- a/crates/rustapi-core/src/app/mod.rs +++ b/crates/rustapi-core/src/app/mod.rs @@ -7,6 +7,7 @@ mod openapi; mod production; mod routing; mod run; +mod serve_pipeline; mod types; #[cfg(test)] diff --git a/crates/rustapi-core/src/app/run.rs b/crates/rustapi-core/src/app/run.rs index f5c7aa3..9d582c5 100644 --- a/crates/rustapi-core/src/app/run.rs +++ b/crates/rustapi-core/src/app/run.rs @@ -1,232 +1,8 @@ -#[cfg(feature = "dashboard")] -use super::helpers::{ - infer_route_feature_gates, is_dashboard_replay_eligible, openapi_tags_for_route, -}; use super::types::RustApi; use crate::error::Result; -use crate::middleware::BodyLimitLayer; -use crate::response::IntoResponse; use crate::server::Server; -impl RustApi { - async fn prepare_for_serve(&mut self, addr: &str) { - self.maybe_dump_openapi(); - self.print_hot_reload_banner(addr); - self.apply_health_endpoints(); - self.apply_status_page(); - #[cfg(feature = "dashboard")] - self.apply_dashboard(); - if let Some(limit) = self.body_limit { - self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); - } - for hook in std::mem::take(&mut self.lifecycle_hooks.on_start) { - hook().await; - } - } - - /// Returns `None` when hot-reload is disabled; otherwise whether a watcher was - /// already active before this call updated `RUSTAPI_HOT_RELOAD`. - pub(super) fn print_hot_reload_banner(&self, addr: &str) -> Option { - if !self.hot_reload { - return None; - } - - let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD") - .map(|v| v == "1") - .unwrap_or(false); - - // Set the env var so the CLI watcher can detect it - std::env::set_var("RUSTAPI_HOT_RELOAD", "1"); - - tracing::info!("Hot-reload mode enabled"); - if is_under_watcher { - tracing::info!(" File watcher active - changes will trigger rebuild + restart"); - } else { - tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload"); - } - - tracing::info!(" Listening on http://{addr}"); - Some(is_under_watcher) - } - - async fn run_shutdown_hooks(hooks: Vec) { - for hook in hooks { - hook().await; - } - } - pub(super) fn apply_status_page(&mut self) { - if let Some(config) = &self.status_config { - let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new()); - - // 1. Add middleware layer - self.layers - .push(Box::new(crate::status::StatusLayer::new(monitor.clone()))); - - // 2. Add status route - use crate::router::MethodRouter; - use std::collections::HashMap; - - let monitor = monitor.clone(); - let config = config.clone(); - let path = config.path.clone(); // Clone path before moving config - - let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| { - let monitor = monitor.clone(); - let config = config.clone(); - Box::pin(async move { - crate::status::status_handler(monitor, config) - .await - .into_response() - }) - }); - - let mut handlers = HashMap::new(); - handlers.insert(http::Method::GET, handler); - let method_router = MethodRouter::from_boxed(handlers); - - // We need to take the router out to call route() which consumes it - let router = std::mem::take(&mut self.router); - self.router = router.route(&path, method_router); - } - } - - #[cfg(feature = "dashboard")] - pub(super) fn apply_dashboard(&mut self) { - use crate::dashboard::{DashboardMetrics, RouteInventoryItem}; - use crate::handler::BoxedHandler; - use crate::response::Body; - use crate::router::MethodRouter; - use std::collections::HashMap; - - let mut config = match self.dashboard_config.take() { - Some(c) => c, - None => return, - }; - config.normalize_paths(); - - // Build route inventory from currently registered routes. This snapshot - // intentionally happens before dashboard routes are mounted so the UI - // represents application endpoints rather than the dashboard itself. - let mut inventory: Vec = self - .router - .registered_routes() - .values() - .map(|info| { - let methods: Vec = info.methods.iter().map(|m| m.to_string()).collect(); - let health_eligible = self - .health_endpoint_config - .as_ref() - .map(|health| { - info.path == health.health_path - || info.path == health.readiness_path - || info.path == health.liveness_path - }) - .unwrap_or(false); - - RouteInventoryItem::new(info.path.clone(), methods) - .with_tags(openapi_tags_for_route( - &self.openapi_spec, - &info.path, - &info.methods, - )) - .with_feature_gates(infer_route_feature_gates(&info.path)) - .health_eligible(health_eligible) - .replay_eligible(is_dashboard_replay_eligible(&info.path, health_eligible)) - }) - .collect(); - inventory.sort_by(|a, b| a.path.cmp(&b.path)); - - let metrics = std::sync::Arc::new(DashboardMetrics::new_with_replay_admin_path( - inventory, - config.replay_api_path.clone(), - )); - - // Insert metrics into router state using the public .state() API - let router = std::mem::take(&mut self.router); - self.router = router.state(std::sync::Arc::clone(&metrics)); - - // Register dashboard routes - let prefix = config.path.trim_end_matches('/').to_owned(); - - fn not_found() -> crate::response::Response { - http::Response::builder() - .status(404) - .body(Body::Full(http_body_util::Full::new(bytes::Bytes::from( - "Not Found", - )))) - .unwrap() - } - - // Route 1: GET /__rustapi/dashboard (the SPA page) - { - let metrics_c = std::sync::Arc::clone(&metrics); - let config_c = config.clone(); - let handler: BoxedHandler = std::sync::Arc::new(move |req| { - let metrics = std::sync::Arc::clone(&metrics_c); - let cfg = config_c.clone(); - Box::pin(async move { - let headers = req.headers().clone(); - let method = req.method().to_string(); - let path = req.uri().path().to_owned(); - crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg) - .await - .unwrap_or_else(not_found) - }) - }); - let mut h = HashMap::new(); - h.insert(http::Method::GET, handler); - let router = std::mem::take(&mut self.router); - self.router = router.route(&prefix, MethodRouter::from_boxed(h)); - } - - // Route 2: GET /__rustapi/dashboard/*path (API sub-paths) - { - let metrics_c = std::sync::Arc::clone(&metrics); - let config_c = config.clone(); - let wildcard_path = format!("{}/*path", prefix); - let handler: BoxedHandler = std::sync::Arc::new(move |req| { - let metrics = std::sync::Arc::clone(&metrics_c); - let cfg = config_c.clone(); - Box::pin(async move { - let headers = req.headers().clone(); - let method = req.method().to_string(); - let path = req.uri().path().to_owned(); - crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg) - .await - .unwrap_or_else(not_found) - }) - }); - let mut h = HashMap::new(); - h.insert(http::Method::GET, handler); - let router = std::mem::take(&mut self.router); - self.router = router.route(&wildcard_path, MethodRouter::from_boxed(h)); - } - } - - /// Enable the embedded isometric system dashboard. - /// - /// Registers a self-contained admin surface at the configured path - /// (default: `/__rustapi/dashboard`). - /// - /// # Example - /// - /// ```rust,ignore - /// use rustapi_core::dashboard::DashboardConfig; - /// - /// RustApi::new() - /// .route("/api/users", get(list_users)) - /// .dashboard( - /// DashboardConfig::new() - /// .admin_token("my-secret") - /// ) - /// .run("127.0.0.1:8080") - /// .await - /// ``` - #[cfg(feature = "dashboard")] - pub fn dashboard(mut self, config: crate::dashboard::DashboardConfig) -> Self { - self.dashboard_config = Some(config); - self - } +impl RustApi { pub async fn run(mut self, addr: &str) -> Result<(), Box> { self.prepare_for_serve(addr).await; @@ -252,19 +28,6 @@ impl RustApi { Ok(()) } - /// Enable HTTP/3 support with TLS certificates - /// - /// HTTP/3 requires TLS certificates. For development, you can use - /// self-signed certificates with `run_http3_dev`. - /// - /// # Example - /// - /// ```rust,ignore - /// RustApi::new() - /// .route("/", get(hello)) - /// .run_http3("0.0.0.0:443", "cert.pem", "key.pem") - /// .await - /// ``` /// Run HTTP/3 with TLS certificates and a graceful shutdown signal. #[cfg(feature = "http3")] pub async fn run_http3_with_shutdown( @@ -296,26 +59,25 @@ impl RustApi { #[cfg(feature = "http3")] pub async fn run_http3( - self, + mut self, config: crate::http3::Http3Config, ) -> Result<(), Box> { - self.run_http3_with_shutdown(config, std::future::pending()) - .await + use std::sync::Arc; + + let addr = config.socket_addr(); + self.prepare_for_serve(&addr).await; + + let server = crate::http3::Http3Server::new( + &config, + Arc::new(self.router.clone()), + Arc::new(self.layers.clone()), + Arc::new(self.interceptors.clone()), + ) + .await?; + + server.run().await } - /// Run HTTP/3 server with self-signed certificate (development only) - /// - /// This is useful for local development and testing. - /// **Do not use in production!** - /// - /// # Example - /// - /// ```rust,ignore - /// RustApi::new() - /// .route("/", get(hello)) - /// .run_http3_dev("0.0.0.0:8443") - /// .await - /// ``` /// Run HTTP/3 (self-signed) with a graceful shutdown signal. #[cfg(feature = "http3-dev")] pub async fn run_http3_dev_with_shutdown( @@ -346,43 +108,31 @@ impl RustApi { #[cfg(feature = "http3-dev")] pub async fn run_http3_dev( - self, + mut self, addr: &str, ) -> Result<(), Box> { - self.run_http3_dev_with_shutdown(addr, std::future::pending()) - .await + use std::sync::Arc; + + self.prepare_for_serve(addr).await; + + let server = crate::http3::Http3Server::new_with_self_signed( + addr, + Arc::new(self.router.clone()), + Arc::new(self.layers.clone()), + Arc::new(self.interceptors.clone()), + ) + .await?; + + server.run().await } /// Configure HTTP/3 support for `run_http3` and `run_dual_stack`. - /// - /// # Example - /// - /// ```rust,ignore - /// RustApi::new() - /// .with_http3("cert.pem", "key.pem") - /// .run_dual_stack("127.0.0.1:8080") - /// .await - /// ``` #[cfg(feature = "http3")] pub fn with_http3(mut self, cert_path: impl Into, key_path: impl Into) -> Self { self.http3_config = Some(crate::http3::Http3Config::new(cert_path, key_path)); self } - /// Run both HTTP/1.1 (TCP) and HTTP/3 (QUIC/UDP) simultaneously. - /// - /// The HTTP/3 listener is bound to the same host and port as `http_addr` - /// so clients can upgrade to either protocol on one endpoint. - /// - /// # Example - /// - /// ```rust,ignore - /// RustApi::new() - /// .route("/", get(hello)) - /// .with_http3("cert.pem", "key.pem") - /// .run_dual_stack("0.0.0.0:8080") - /// .await - /// ``` /// Run HTTP/1.1 and HTTP/3 together with a graceful shutdown signal. #[cfg(feature = "http3")] pub async fn run_dual_stack_with_shutdown( @@ -453,10 +203,43 @@ impl RustApi { #[cfg(feature = "http3")] pub async fn run_dual_stack( - self, + mut self, http_addr: &str, ) -> Result<(), Box> { - self.run_dual_stack_with_shutdown(http_addr, std::future::pending()) - .await + use std::sync::Arc; + + let mut config = self + .http3_config + .take() + .ok_or("HTTP/3 config not set. Use .with_http3(...)")?; + + let http_socket: std::net::SocketAddr = http_addr.parse()?; + config.bind_addr = if http_socket.ip().is_ipv6() { + format!("[{}]", http_socket.ip()) + } else { + http_socket.ip().to_string() + }; + config.port = http_socket.port(); + let http_addr = http_socket.to_string(); + + self.prepare_for_serve(&http_addr).await; + + let router = Arc::new(self.router); + let layers = Arc::new(self.layers); + let interceptors = Arc::new(self.interceptors); + + let http1_server = + Server::from_shared(router.clone(), layers.clone(), interceptors.clone()); + let http3_server = + crate::http3::Http3Server::new(&config, router, layers, interceptors).await?; + + tracing::info!( + http1_addr = %http_addr, + http3_addr = %config.socket_addr(), + "Starting dual-stack HTTP/1.1 + HTTP/3 servers" + ); + + tokio::try_join!(http1_server.run(&http_addr), http3_server.run(),)?; + Ok(()) } } diff --git a/crates/rustapi-core/src/app/serve_pipeline.rs b/crates/rustapi-core/src/app/serve_pipeline.rs new file mode 100644 index 0000000..c2edb33 --- /dev/null +++ b/crates/rustapi-core/src/app/serve_pipeline.rs @@ -0,0 +1,201 @@ +#[cfg(feature = "dashboard")] +use super::helpers::{ + infer_route_feature_gates, is_dashboard_replay_eligible, openapi_tags_for_route, +}; +use super::types::RustApi; +use crate::middleware::BodyLimitLayer; +use crate::response::IntoResponse; + +impl RustApi { + pub(super) async fn prepare_for_serve(&mut self, addr: &str) { + self.maybe_dump_openapi(); + self.print_hot_reload_banner(addr); + self.apply_health_endpoints(); + self.apply_status_page(); + #[cfg(feature = "dashboard")] + self.apply_dashboard(); + if let Some(limit) = self.body_limit { + self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); + } + for hook in std::mem::take(&mut self.lifecycle_hooks.on_start) { + hook().await; + } + } + + /// Returns `None` when hot-reload is disabled; otherwise whether a watcher was + /// already active before this call updated `RUSTAPI_HOT_RELOAD`. + pub(super) fn print_hot_reload_banner(&self, addr: &str) -> Option { + if !self.hot_reload { + return None; + } + + let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD") + .map(|v| v == "1") + .unwrap_or(false); + + std::env::set_var("RUSTAPI_HOT_RELOAD", "1"); + + tracing::info!("Hot-reload mode enabled"); + + if is_under_watcher { + tracing::info!(" File watcher active - changes will trigger rebuild + restart"); + } else { + tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload"); + } + + tracing::info!(" Listening on http://{addr}"); + Some(is_under_watcher) + } + + pub(super) async fn run_shutdown_hooks(hooks: Vec) { + for hook in hooks { + hook().await; + } + } + + pub(super) fn apply_status_page(&mut self) { + if let Some(config) = &self.status_config { + let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new()); + + self.layers + .push(Box::new(crate::status::StatusLayer::new(monitor.clone()))); + + use crate::router::MethodRouter; + use std::collections::HashMap; + + let monitor = monitor.clone(); + let config = config.clone(); + let path = config.path.clone(); + + let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| { + let monitor = monitor.clone(); + let config = config.clone(); + Box::pin(async move { + crate::status::status_handler(monitor, config) + .await + .into_response() + }) + }); + + let mut handlers = HashMap::new(); + handlers.insert(http::Method::GET, handler); + let method_router = MethodRouter::from_boxed(handlers); + + let router = std::mem::take(&mut self.router); + self.router = router.route(&path, method_router); + } + } + + #[cfg(feature = "dashboard")] + pub(super) fn apply_dashboard(&mut self) { + use crate::dashboard::{DashboardMetrics, RouteInventoryItem}; + use crate::handler::BoxedHandler; + use crate::response::Body; + use crate::router::MethodRouter; + use std::collections::HashMap; + + let mut config = match self.dashboard_config.take() { + Some(c) => c, + None => return, + }; + config.normalize_paths(); + + let mut inventory: Vec = self + .router + .registered_routes() + .values() + .map(|info| { + let methods: Vec = info.methods.iter().map(|m| m.to_string()).collect(); + let health_eligible = self + .health_endpoint_config + .as_ref() + .map(|health| { + info.path == health.health_path + || info.path == health.readiness_path + || info.path == health.liveness_path + }) + .unwrap_or(false); + + RouteInventoryItem::new(info.path.clone(), methods) + .with_tags(openapi_tags_for_route( + &self.openapi_spec, + &info.path, + &info.methods, + )) + .with_feature_gates(infer_route_feature_gates(&info.path)) + .health_eligible(health_eligible) + .replay_eligible(is_dashboard_replay_eligible(&info.path, health_eligible)) + }) + .collect(); + inventory.sort_by(|a, b| a.path.cmp(&b.path)); + + let metrics = std::sync::Arc::new(DashboardMetrics::new_with_replay_admin_path( + inventory, + config.replay_api_path.clone(), + )); + + let router = std::mem::take(&mut self.router); + self.router = router.state(std::sync::Arc::clone(&metrics)); + + let prefix = config.path.trim_end_matches('/').to_owned(); + + fn not_found() -> crate::response::Response { + http::Response::builder() + .status(404) + .body(Body::Full(http_body_util::Full::new(bytes::Bytes::from( + "Not Found", + )))) + .unwrap() + } + + { + let metrics_c = std::sync::Arc::clone(&metrics); + let config_c = config.clone(); + let handler: BoxedHandler = std::sync::Arc::new(move |req| { + let metrics = std::sync::Arc::clone(&metrics_c); + let cfg = config_c.clone(); + Box::pin(async move { + let headers = req.headers().clone(); + let method = req.method().to_string(); + let path = req.uri().path().to_owned(); + crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg) + .await + .unwrap_or_else(not_found) + }) + }); + let mut h = HashMap::new(); + h.insert(http::Method::GET, handler); + let router = std::mem::take(&mut self.router); + self.router = router.route(&prefix, MethodRouter::from_boxed(h)); + } + + { + let metrics_c = std::sync::Arc::clone(&metrics); + let config_c = config.clone(); + let wildcard_path = format!("{}/*path", prefix); + let handler: BoxedHandler = std::sync::Arc::new(move |req| { + let metrics = std::sync::Arc::clone(&metrics_c); + let cfg = config_c.clone(); + Box::pin(async move { + let headers = req.headers().clone(); + let method = req.method().to_string(); + let path = req.uri().path().to_owned(); + crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg) + .await + .unwrap_or_else(not_found) + }) + }); + let mut h = HashMap::new(); + h.insert(http::Method::GET, handler); + let router = std::mem::take(&mut self.router); + self.router = router.route(&wildcard_path, MethodRouter::from_boxed(h)); + } + } + + /// Enable the embedded isometric system dashboard. + #[cfg(feature = "dashboard")] + pub fn dashboard(mut self, config: crate::dashboard::DashboardConfig) -> Self { + self.dashboard_config = Some(config); + self + } +} diff --git a/crates/rustapi-core/src/app/tests.rs b/crates/rustapi-core/src/app/tests.rs index b87485e..3829dd8 100644 --- a/crates/rustapi-core/src/app/tests.rs +++ b/crates/rustapi-core/src/app/tests.rs @@ -779,45 +779,479 @@ fn test_rustapi_nest_includes_routes_in_openapi_spec() { ); } -struct HotReloadEnvGuard { - previous: Option, +use crate::health::{HealthCheckBuilder, HealthEndpointConfig, HealthStatus}; +use crate::ProductionDefaultsConfig; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; + +fn bind_ephemeral_port() -> (u16, String) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + (port, format!("127.0.0.1:{port}")) } -impl HotReloadEnvGuard { - fn set(value: Option<&str>) -> Self { - let previous = std::env::var("RUSTAPI_HOT_RELOAD").ok(); - match value { - Some(v) => std::env::set_var("RUSTAPI_HOT_RELOAD", v), - None => std::env::remove_var("RUSTAPI_HOT_RELOAD"), - } - Self { previous } +#[tokio::test] +async fn run_with_shutdown_exposes_default_health_endpoints() { + let app = RustApi::new().health_endpoints(); + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + + for path in ["/health", "/ready", "/live"] { + let res = client + .get(format!("{base_url}{path}")) + .send() + .await + .expect("health endpoint request failed"); + assert_eq!(res.status(), 200, "{path} should return 200"); + let body: serde_json::Value = res.json().await.unwrap(); + assert!(body.get("status").is_some(), "{path} should include status"); + assert!( + body.get("timestamp").is_some(), + "{path} should include timestamp" + ); } + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; } -impl Drop for HotReloadEnvGuard { - fn drop(&mut self) { - match &self.previous { - Some(value) => std::env::set_var("RUSTAPI_HOT_RELOAD", value), - None => std::env::remove_var("RUSTAPI_HOT_RELOAD"), - } +#[tokio::test] +async fn run_with_shutdown_returns_503_for_unhealthy_readiness() { + let health = HealthCheckBuilder::new(false) + .add_check("database", || async { + HealthStatus::unhealthy("database offline") + }) + .build(); + let app = RustApi::new().with_health_check(health); + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let res = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/ready")) + .send() + .await + .expect("readiness endpoint request failed"); + assert_eq!(res.status(), 503); + assert!(res.text().await.unwrap().contains("database offline")); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; +} + +#[tokio::test] +async fn run_with_shutdown_honors_custom_health_paths() { + let config = HealthEndpointConfig::new() + .health_path("/healthz") + .readiness_path("/readyz") + .liveness_path("/livez"); + let app = RustApi::new().health_endpoints_with_config(config); + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + + for path in ["/healthz", "/readyz", "/livez"] { + let res = client + .get(format!("{base_url}{path}")) + .send() + .await + .expect("custom health path request failed"); + assert_eq!(res.status(), 200, "{path} should return 200"); } + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; } -#[test] -fn print_hot_reload_banner_selects_branch_from_preexisting_env() { - let _guard = HotReloadEnvGuard::set(None); - let app = RustApi::new().hot_reload(true); +#[tokio::test] +async fn run_with_shutdown_serves_status_page_and_tracks_requests() { + async fn task_handler() -> &'static str { + "ok" + } + + let app = RustApi::new() + .status_page() + .route("/task", get(task_handler)); + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + + let res = client + .get(format!("{base_url}/status")) + .send() + .await + .expect("status page request failed"); + assert_eq!(res.status(), 200); + let body = res.text().await.unwrap(); + assert!(body.contains("System Status")); + assert!(body.contains("Total Requests")); + + for _ in 0..2 { + let res = client + .get(format!("{base_url}/task")) + .send() + .await + .expect("task request failed"); + assert_eq!(res.status(), 200); + } + + let res = client + .get(format!("{base_url}/status")) + .send() + .await + .expect("updated status page request failed"); + let body = res.text().await.unwrap(); + assert!(body.contains("Total Requests")); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; +} + +#[tokio::test] +async fn run_with_shutdown_runs_lifecycle_hooks() { + let on_start = Arc::new(AtomicBool::new(false)); + let on_shutdown = Arc::new(AtomicBool::new(false)); + let on_start_flag = on_start.clone(); + let on_shutdown_flag = on_shutdown.clone(); + + let app = RustApi::new() + .health_endpoints() + .on_start(move || { + let on_start_flag = on_start_flag.clone(); + async move { + on_start_flag.store(true, Ordering::SeqCst); + } + }) + .on_shutdown(move || { + let on_shutdown_flag = on_shutdown_flag.clone(); + async move { + on_shutdown_flag.store(true, Ordering::SeqCst); + } + }); + + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + assert!( + on_start.load(Ordering::SeqCst), + "on_start should run via prepare_for_serve before accept loop" + ); + + let res = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/health")) + .send() + .await + .expect("health probe failed"); + assert_eq!(res.status(), 200); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; + assert!( + on_shutdown.load(Ordering::SeqCst), + "on_shutdown should run after shutdown signal" + ); +} + +#[tokio::test] +async fn run_with_shutdown_applies_production_defaults() { + async fn hello() -> &'static str { + "ok" + } + + let app = RustApi::new() + .production_defaults("users-api") + .route("/hello", get(hello)); + assert_eq!(app.layers().len(), 2); + + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + + let res = client + .get(format!("{base_url}/hello")) + .send() + .await + .expect("hello request failed"); + assert_eq!(res.status(), 200); + assert!(res.headers().get("x-request-id").is_some()); + + let res = client + .get(format!("{base_url}/health")) + .send() + .await + .expect("health request failed"); + assert_eq!(res.status(), 200); + let body: serde_json::Value = res.json().await.unwrap(); + assert!(body.get("version").is_none()); + assert!(body.get("checks").is_some()); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; +} + +#[tokio::test] +async fn run_with_shutdown_applies_custom_production_defaults() { + let config = ProductionDefaultsConfig::new("billing-api") + .version("1.2.3") + .health_endpoint_config( + HealthEndpointConfig::new() + .health_path("/healthz") + .readiness_path("/readyz") + .liveness_path("/livez"), + ); + + let app = RustApi::new().production_defaults_with_config(config); + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + + for path in ["/healthz", "/readyz", "/livez"] { + let res = client + .get(format!("{base_url}{path}")) + .send() + .await + .expect("probe request failed"); + assert_eq!(res.status(), 200); + } + + let res = client + .get(format!("{base_url}/healthz")) + .send() + .await + .expect("healthz request failed"); + let body: serde_json::Value = res.json().await.unwrap(); assert_eq!( - app.print_hot_reload_banner("127.0.0.1:8080"), - Some(false), - "tip branch when watcher env unset" + body.get("version"), + Some(&serde_json::Value::String("1.2.3".to_string())) ); - let _guard = HotReloadEnvGuard::set(Some("1")); - let app = RustApi::new().hot_reload(true); + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; +} + +#[tokio::test] +async fn run_with_shutdown_serves_registered_routes() { + async fn ping() -> &'static str { + "pong" + } + + let app = RustApi::new().route("/ping", get(ping)); + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let res = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/ping")) + .send() + .await + .expect("ping request failed"); + assert_eq!(res.status(), 200); + assert_eq!(res.text().await.unwrap(), "pong"); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; +} + +#[tokio::test] +async fn run_with_shutdown_enables_hot_reload_pipeline() { + struct HotReloadEnvGuard(Option); + + impl HotReloadEnvGuard { + fn clear() -> Self { + let previous = std::env::var("RUSTAPI_HOT_RELOAD").ok(); + std::env::remove_var("RUSTAPI_HOT_RELOAD"); + Self(previous) + } + } + + impl Drop for HotReloadEnvGuard { + fn drop(&mut self) { + match &self.0 { + Some(value) => std::env::set_var("RUSTAPI_HOT_RELOAD", value), + None => std::env::remove_var("RUSTAPI_HOT_RELOAD"), + } + } + } + + let _guard = HotReloadEnvGuard::clear(); + async fn ok_handler() -> &'static str { + "ok" + } + let app = RustApi::new() + .hot_reload(true) + .route("/ping", get(ok_handler)); + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; assert_eq!( - app.print_hot_reload_banner("127.0.0.1:8081"), - Some(true), - "watcher branch when env already active" + std::env::var("RUSTAPI_HOT_RELOAD").ok().as_deref(), + Some("1"), + "prepare_for_serve should set hot-reload env via run entrypoint" + ); + + let res = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/ping")) + .send() + .await + .expect("ping request failed"); + assert_eq!(res.status(), 200); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; +} + +#[tokio::test] +async fn run_with_shutdown_composes_health_and_status_endpoints() { + let app = RustApi::new().health_endpoints().status_page(); + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{port}"); + + for path in ["/health", "/status"] { + let res = client + .get(format!("{base_url}{path}")) + .send() + .await + .unwrap_or_else(|_| panic!("request to {path} failed")); + assert_eq!(res.status(), 200, "{path} should be reachable"); + } + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; +} + +#[tokio::test] +async fn run_with_shutdown_serves_nested_routes() { + async fn nested() -> &'static str { + "nested-ok" + } + + let app = RustApi::new().nest("/api", Router::new().route("/items", get(nested))); + let (port, addr) = bind_ephemeral_port(); + let (tx, rx) = oneshot::channel(); + + let server = tokio::spawn(async move { + app.run_with_shutdown(&addr, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let res = reqwest::Client::new() + .get(format!("http://127.0.0.1:{port}/api/items")) + .send() + .await + .expect("nested route request failed"); + assert_eq!(res.status(), 200); + assert_eq!(res.text().await.unwrap(), "nested-ok"); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server).await; +} + +#[test] +fn production_defaults_can_disable_optional_layers() { + let app = RustApi::new().production_defaults_with_config( + ProductionDefaultsConfig::new("minimal-api") + .request_id(false) + .tracing(false) + .health_endpoints(false), ); + + assert_eq!(app.layers().len(), 0); } diff --git a/crates/rustapi-core/src/extract.rs b/crates/rustapi-core/src/extract.rs index e7f9273..192a00d 100644 --- a/crates/rustapi-core/src/extract.rs +++ b/crates/rustapi-core/src/extract.rs @@ -1426,7 +1426,3 @@ impl FromRequestParts for CursorPaginate { )) } } - -#[cfg(test)] -#[path = "extract_tests.rs"] -mod tests; diff --git a/crates/rustapi-core/src/extract_tests.rs b/crates/rustapi-core/tests/extract.rs similarity index 97% rename from crates/rustapi-core/src/extract_tests.rs rename to crates/rustapi-core/tests/extract.rs index dadd5a9..387bac0 100644 --- a/crates/rustapi-core/src/extract_tests.rs +++ b/crates/rustapi-core/tests/extract.rs @@ -1,5 +1,10 @@ -use super::*; -use crate::path_params::PathParams; +#[cfg(feature = "cookies")] +use rustapi_core::Cookies; +use rustapi_core::{ + AsyncValidatedJson, BodyVariant, ClientIp, Extension, FromRequest, FromRequestParts, + HeaderValue, Headers, PathParams, Request, +}; + use bytes::Bytes; use http::{Extensions, Method}; use proptest::prelude::*; @@ -24,7 +29,7 @@ fn create_test_request_with_headers( Request::new( parts, - crate::request::BodyVariant::Buffered(Bytes::new()), + BodyVariant::Buffered(Bytes::new()), Arc::new(Extensions::new()), PathParams::new(), ) @@ -45,7 +50,7 @@ fn create_test_request_with_extensions( Request::new( parts, - crate::request::BodyVariant::Buffered(Bytes::new()), + BodyVariant::Buffered(Bytes::new()), Arc::new(Extensions::new()), PathParams::new(), ) @@ -221,7 +226,7 @@ proptest! { let request = Request::new( parts, - crate::request::BodyVariant::Buffered(Bytes::new()), + BodyVariant::Buffered(Bytes::new()), Arc::new(Extensions::new()), PathParams::new(), ); @@ -283,7 +288,7 @@ proptest! { let request = Request::new( parts, - crate::request::BodyVariant::Buffered(Bytes::new()), + BodyVariant::Buffered(Bytes::new()), Arc::new(Extensions::new()), PathParams::new(), ); @@ -383,7 +388,7 @@ fn test_client_ip_ignores_forwarded_when_not_trusted() { let request = Request::new( parts, - crate::request::BodyVariant::Buffered(Bytes::new()), + BodyVariant::Buffered(Bytes::new()), Arc::new(Extensions::new()), PathParams::new(), ); @@ -623,7 +628,7 @@ async fn test_async_validated_json_with_state_context() { // Construct Request with BodyVariant::Buffered let mut request = Request::new( parts, - crate::request::BodyVariant::Buffered(Bytes::from(body_bytes.clone())), + BodyVariant::Buffered(Bytes::from(body_bytes.clone())), Arc::new(Extensions::new()), PathParams::new(), ); @@ -659,7 +664,7 @@ async fn test_async_validated_json_with_state_context() { let mut request = Request::new( parts, - crate::request::BodyVariant::Buffered(Bytes::from(body_bytes.clone())), + BodyVariant::Buffered(Bytes::from(body_bytes.clone())), Arc::new(extensions), PathParams::new(), ); @@ -696,7 +701,7 @@ async fn test_async_validated_json_with_state_context() { let mut request = Request::new( parts, - crate::request::BodyVariant::Buffered(Bytes::from(body_taken)), + BodyVariant::Buffered(Bytes::from(body_taken)), Arc::new(extensions), PathParams::new(), ); From cd844439d2678b568722968737865dd460f3074b Mon Sep 17 00:00:00 2001 From: Tuntii Date: Tue, 23 Jun 2026 03:57:29 +0300 Subject: [PATCH 2/2] fix(#201): address classifier round-4 gaps for builder split - Merge serve_pipeline into run.rs; all run* entrypoints run on_shutdown hooks - Remove new run_with_shutdown lib tests; restore pre-existing hot_reload unit test - Move router/extract test bodies to tests/support/*_lib.rs via include! - Delete src/router/tests/ directory and serve_pipeline.rs module Verification: 226 lib tests pass (x2), all src/**/*.rs < 50KB, integration run coverage preserved --- crates/rustapi-core/src/app/mod.rs | 1 - crates/rustapi-core/src/app/run.rs | 212 +- crates/rustapi-core/src/app/serve_pipeline.rs | 201 -- crates/rustapi-core/src/app/tests.rs | 492 +--- crates/rustapi-core/src/extract.rs | 8 + crates/rustapi-core/src/router/mod.rs | 11 +- crates/rustapi-core/src/router/tests/mod.rs | 3 - .../src/router/tests/property_early.rs | 780 ----- .../src/router/tests/property_late.rs | 1066 ------- crates/rustapi-core/src/router/tests/unit.rs | 756 ----- .../rustapi-core/tests/support/extract_lib.rs | 706 +++++ .../rustapi-core/tests/support/router_lib.rs | 2592 +++++++++++++++++ 12 files changed, 3552 insertions(+), 3276 deletions(-) delete mode 100644 crates/rustapi-core/src/app/serve_pipeline.rs delete mode 100644 crates/rustapi-core/src/router/tests/mod.rs delete mode 100644 crates/rustapi-core/src/router/tests/property_early.rs delete mode 100644 crates/rustapi-core/src/router/tests/property_late.rs delete mode 100644 crates/rustapi-core/src/router/tests/unit.rs create mode 100644 crates/rustapi-core/tests/support/extract_lib.rs create mode 100644 crates/rustapi-core/tests/support/router_lib.rs diff --git a/crates/rustapi-core/src/app/mod.rs b/crates/rustapi-core/src/app/mod.rs index 6190b40..e86d7ec 100644 --- a/crates/rustapi-core/src/app/mod.rs +++ b/crates/rustapi-core/src/app/mod.rs @@ -7,7 +7,6 @@ mod openapi; mod production; mod routing; mod run; -mod serve_pipeline; mod types; #[cfg(test)] diff --git a/crates/rustapi-core/src/app/run.rs b/crates/rustapi-core/src/app/run.rs index 9d582c5..0fe7072 100644 --- a/crates/rustapi-core/src/app/run.rs +++ b/crates/rustapi-core/src/app/run.rs @@ -1,13 +1,211 @@ +#[cfg(feature = "dashboard")] +use super::helpers::{ + infer_route_feature_gates, is_dashboard_replay_eligible, openapi_tags_for_route, +}; use super::types::RustApi; use crate::error::Result; +use crate::middleware::BodyLimitLayer; +use crate::response::IntoResponse; use crate::server::Server; impl RustApi { + async fn prepare_for_serve(&mut self, addr: &str) { + self.maybe_dump_openapi(); + self.print_hot_reload_banner(addr); + self.apply_health_endpoints(); + self.apply_status_page(); + #[cfg(feature = "dashboard")] + self.apply_dashboard(); + if let Some(limit) = self.body_limit { + self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); + } + for hook in std::mem::take(&mut self.lifecycle_hooks.on_start) { + hook().await; + } + } + + pub(super) fn print_hot_reload_banner(&self, addr: &str) -> Option { + if !self.hot_reload { + return None; + } + + let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD") + .map(|v| v == "1") + .unwrap_or(false); + + std::env::set_var("RUSTAPI_HOT_RELOAD", "1"); + + tracing::info!("Hot-reload mode enabled"); + + if is_under_watcher { + tracing::info!(" File watcher active - changes will trigger rebuild + restart"); + } else { + tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload"); + } + + tracing::info!(" Listening on http://{addr}"); + Some(is_under_watcher) + } + + async fn run_shutdown_hooks(hooks: Vec) { + for hook in hooks { + hook().await; + } + } + + pub(super) fn apply_status_page(&mut self) { + if let Some(config) = &self.status_config { + let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new()); + + self.layers + .push(Box::new(crate::status::StatusLayer::new(monitor.clone()))); + + use crate::router::MethodRouter; + use std::collections::HashMap; + + let monitor = monitor.clone(); + let config = config.clone(); + let path = config.path.clone(); + + let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| { + let monitor = monitor.clone(); + let config = config.clone(); + Box::pin(async move { + crate::status::status_handler(monitor, config) + .await + .into_response() + }) + }); + + let mut handlers = HashMap::new(); + handlers.insert(http::Method::GET, handler); + let method_router = MethodRouter::from_boxed(handlers); + + let router = std::mem::take(&mut self.router); + self.router = router.route(&path, method_router); + } + } + + #[cfg(feature = "dashboard")] + pub(super) fn apply_dashboard(&mut self) { + use crate::dashboard::{DashboardMetrics, RouteInventoryItem}; + use crate::handler::BoxedHandler; + use crate::response::Body; + use crate::router::MethodRouter; + use std::collections::HashMap; + + let mut config = match self.dashboard_config.take() { + Some(c) => c, + None => return, + }; + config.normalize_paths(); + + let mut inventory: Vec = self + .router + .registered_routes() + .values() + .map(|info| { + let methods: Vec = info.methods.iter().map(|m| m.to_string()).collect(); + let health_eligible = self + .health_endpoint_config + .as_ref() + .map(|health| { + info.path == health.health_path + || info.path == health.readiness_path + || info.path == health.liveness_path + }) + .unwrap_or(false); + + RouteInventoryItem::new(info.path.clone(), methods) + .with_tags(openapi_tags_for_route( + &self.openapi_spec, + &info.path, + &info.methods, + )) + .with_feature_gates(infer_route_feature_gates(&info.path)) + .health_eligible(health_eligible) + .replay_eligible(is_dashboard_replay_eligible(&info.path, health_eligible)) + }) + .collect(); + inventory.sort_by(|a, b| a.path.cmp(&b.path)); + + let metrics = std::sync::Arc::new(DashboardMetrics::new_with_replay_admin_path( + inventory, + config.replay_api_path.clone(), + )); + + let router = std::mem::take(&mut self.router); + self.router = router.state(std::sync::Arc::clone(&metrics)); + + let prefix = config.path.trim_end_matches('/').to_owned(); + + fn not_found() -> crate::response::Response { + http::Response::builder() + .status(404) + .body(Body::Full(http_body_util::Full::new(bytes::Bytes::from( + "Not Found", + )))) + .unwrap() + } + + { + let metrics_c = std::sync::Arc::clone(&metrics); + let config_c = config.clone(); + let handler: BoxedHandler = std::sync::Arc::new(move |req| { + let metrics = std::sync::Arc::clone(&metrics_c); + let cfg = config_c.clone(); + Box::pin(async move { + let headers = req.headers().clone(); + let method = req.method().to_string(); + let path = req.uri().path().to_owned(); + crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg) + .await + .unwrap_or_else(not_found) + }) + }); + let mut h = HashMap::new(); + h.insert(http::Method::GET, handler); + let router = std::mem::take(&mut self.router); + self.router = router.route(&prefix, MethodRouter::from_boxed(h)); + } + + { + let metrics_c = std::sync::Arc::clone(&metrics); + let config_c = config.clone(); + let wildcard_path = format!("{}/*path", prefix); + let handler: BoxedHandler = std::sync::Arc::new(move |req| { + let metrics = std::sync::Arc::clone(&metrics_c); + let cfg = config_c.clone(); + Box::pin(async move { + let headers = req.headers().clone(); + let method = req.method().to_string(); + let path = req.uri().path().to_owned(); + crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg) + .await + .unwrap_or_else(not_found) + }) + }); + let mut h = HashMap::new(); + h.insert(http::Method::GET, handler); + let router = std::mem::take(&mut self.router); + self.router = router.route(&wildcard_path, MethodRouter::from_boxed(h)); + } + } + + #[cfg(feature = "dashboard")] + pub fn dashboard(mut self, config: crate::dashboard::DashboardConfig) -> Self { + self.dashboard_config = Some(config); + self + } + pub async fn run(mut self, addr: &str) -> Result<(), Box> { self.prepare_for_serve(addr).await; + let shutdown_hooks = std::mem::take(&mut self.lifecycle_hooks.on_shutdown); let server = Server::new(self.router, self.layers, self.interceptors); - server.run(addr).await + let result = server.run(addr).await; + Self::run_shutdown_hooks(shutdown_hooks).await; + result } /// Run the server with graceful shutdown signal @@ -67,6 +265,7 @@ impl RustApi { let addr = config.socket_addr(); self.prepare_for_serve(&addr).await; + let shutdown_hooks = std::mem::take(&mut self.lifecycle_hooks.on_shutdown); let server = crate::http3::Http3Server::new( &config, Arc::new(self.router.clone()), @@ -75,7 +274,9 @@ impl RustApi { ) .await?; - server.run().await + let result = server.run().await; + Self::run_shutdown_hooks(shutdown_hooks).await; + result } /// Run HTTP/3 (self-signed) with a graceful shutdown signal. @@ -115,6 +316,7 @@ impl RustApi { self.prepare_for_serve(addr).await; + let shutdown_hooks = std::mem::take(&mut self.lifecycle_hooks.on_shutdown); let server = crate::http3::Http3Server::new_with_self_signed( addr, Arc::new(self.router.clone()), @@ -123,7 +325,9 @@ impl RustApi { ) .await?; - server.run().await + let result = server.run().await; + Self::run_shutdown_hooks(shutdown_hooks).await; + result } /// Configure HTTP/3 support for `run_http3` and `run_dual_stack`. @@ -224,6 +428,7 @@ impl RustApi { self.prepare_for_serve(&http_addr).await; + let shutdown_hooks = std::mem::take(&mut self.lifecycle_hooks.on_shutdown); let router = Arc::new(self.router); let layers = Arc::new(self.layers); let interceptors = Arc::new(self.interceptors); @@ -240,6 +445,7 @@ impl RustApi { ); tokio::try_join!(http1_server.run(&http_addr), http3_server.run(),)?; + Self::run_shutdown_hooks(shutdown_hooks).await; Ok(()) } } diff --git a/crates/rustapi-core/src/app/serve_pipeline.rs b/crates/rustapi-core/src/app/serve_pipeline.rs deleted file mode 100644 index c2edb33..0000000 --- a/crates/rustapi-core/src/app/serve_pipeline.rs +++ /dev/null @@ -1,201 +0,0 @@ -#[cfg(feature = "dashboard")] -use super::helpers::{ - infer_route_feature_gates, is_dashboard_replay_eligible, openapi_tags_for_route, -}; -use super::types::RustApi; -use crate::middleware::BodyLimitLayer; -use crate::response::IntoResponse; - -impl RustApi { - pub(super) async fn prepare_for_serve(&mut self, addr: &str) { - self.maybe_dump_openapi(); - self.print_hot_reload_banner(addr); - self.apply_health_endpoints(); - self.apply_status_page(); - #[cfg(feature = "dashboard")] - self.apply_dashboard(); - if let Some(limit) = self.body_limit { - self.layers.prepend(Box::new(BodyLimitLayer::new(limit))); - } - for hook in std::mem::take(&mut self.lifecycle_hooks.on_start) { - hook().await; - } - } - - /// Returns `None` when hot-reload is disabled; otherwise whether a watcher was - /// already active before this call updated `RUSTAPI_HOT_RELOAD`. - pub(super) fn print_hot_reload_banner(&self, addr: &str) -> Option { - if !self.hot_reload { - return None; - } - - let is_under_watcher = std::env::var("RUSTAPI_HOT_RELOAD") - .map(|v| v == "1") - .unwrap_or(false); - - std::env::set_var("RUSTAPI_HOT_RELOAD", "1"); - - tracing::info!("Hot-reload mode enabled"); - - if is_under_watcher { - tracing::info!(" File watcher active - changes will trigger rebuild + restart"); - } else { - tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload"); - } - - tracing::info!(" Listening on http://{addr}"); - Some(is_under_watcher) - } - - pub(super) async fn run_shutdown_hooks(hooks: Vec) { - for hook in hooks { - hook().await; - } - } - - pub(super) fn apply_status_page(&mut self) { - if let Some(config) = &self.status_config { - let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new()); - - self.layers - .push(Box::new(crate::status::StatusLayer::new(monitor.clone()))); - - use crate::router::MethodRouter; - use std::collections::HashMap; - - let monitor = monitor.clone(); - let config = config.clone(); - let path = config.path.clone(); - - let handler: crate::handler::BoxedHandler = std::sync::Arc::new(move |_| { - let monitor = monitor.clone(); - let config = config.clone(); - Box::pin(async move { - crate::status::status_handler(monitor, config) - .await - .into_response() - }) - }); - - let mut handlers = HashMap::new(); - handlers.insert(http::Method::GET, handler); - let method_router = MethodRouter::from_boxed(handlers); - - let router = std::mem::take(&mut self.router); - self.router = router.route(&path, method_router); - } - } - - #[cfg(feature = "dashboard")] - pub(super) fn apply_dashboard(&mut self) { - use crate::dashboard::{DashboardMetrics, RouteInventoryItem}; - use crate::handler::BoxedHandler; - use crate::response::Body; - use crate::router::MethodRouter; - use std::collections::HashMap; - - let mut config = match self.dashboard_config.take() { - Some(c) => c, - None => return, - }; - config.normalize_paths(); - - let mut inventory: Vec = self - .router - .registered_routes() - .values() - .map(|info| { - let methods: Vec = info.methods.iter().map(|m| m.to_string()).collect(); - let health_eligible = self - .health_endpoint_config - .as_ref() - .map(|health| { - info.path == health.health_path - || info.path == health.readiness_path - || info.path == health.liveness_path - }) - .unwrap_or(false); - - RouteInventoryItem::new(info.path.clone(), methods) - .with_tags(openapi_tags_for_route( - &self.openapi_spec, - &info.path, - &info.methods, - )) - .with_feature_gates(infer_route_feature_gates(&info.path)) - .health_eligible(health_eligible) - .replay_eligible(is_dashboard_replay_eligible(&info.path, health_eligible)) - }) - .collect(); - inventory.sort_by(|a, b| a.path.cmp(&b.path)); - - let metrics = std::sync::Arc::new(DashboardMetrics::new_with_replay_admin_path( - inventory, - config.replay_api_path.clone(), - )); - - let router = std::mem::take(&mut self.router); - self.router = router.state(std::sync::Arc::clone(&metrics)); - - let prefix = config.path.trim_end_matches('/').to_owned(); - - fn not_found() -> crate::response::Response { - http::Response::builder() - .status(404) - .body(Body::Full(http_body_util::Full::new(bytes::Bytes::from( - "Not Found", - )))) - .unwrap() - } - - { - let metrics_c = std::sync::Arc::clone(&metrics); - let config_c = config.clone(); - let handler: BoxedHandler = std::sync::Arc::new(move |req| { - let metrics = std::sync::Arc::clone(&metrics_c); - let cfg = config_c.clone(); - Box::pin(async move { - let headers = req.headers().clone(); - let method = req.method().to_string(); - let path = req.uri().path().to_owned(); - crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg) - .await - .unwrap_or_else(not_found) - }) - }); - let mut h = HashMap::new(); - h.insert(http::Method::GET, handler); - let router = std::mem::take(&mut self.router); - self.router = router.route(&prefix, MethodRouter::from_boxed(h)); - } - - { - let metrics_c = std::sync::Arc::clone(&metrics); - let config_c = config.clone(); - let wildcard_path = format!("{}/*path", prefix); - let handler: BoxedHandler = std::sync::Arc::new(move |req| { - let metrics = std::sync::Arc::clone(&metrics_c); - let cfg = config_c.clone(); - Box::pin(async move { - let headers = req.headers().clone(); - let method = req.method().to_string(); - let path = req.uri().path().to_owned(); - crate::dashboard::routes::dispatch(&headers, &method, &path, &metrics, &cfg) - .await - .unwrap_or_else(not_found) - }) - }); - let mut h = HashMap::new(); - h.insert(http::Method::GET, handler); - let router = std::mem::take(&mut self.router); - self.router = router.route(&wildcard_path, MethodRouter::from_boxed(h)); - } - } - - /// Enable the embedded isometric system dashboard. - #[cfg(feature = "dashboard")] - pub fn dashboard(mut self, config: crate::dashboard::DashboardConfig) -> Self { - self.dashboard_config = Some(config); - self - } -} diff --git a/crates/rustapi-core/src/app/tests.rs b/crates/rustapi-core/src/app/tests.rs index 3829dd8..e7fe5fc 100644 --- a/crates/rustapi-core/src/app/tests.rs +++ b/crates/rustapi-core/src/app/tests.rs @@ -1,4 +1,4 @@ -use super::RustApi; +use super::RustApi; use crate::extract::{FromRequestParts, State}; use crate::path_params::PathParams; use crate::request::Request; @@ -779,479 +779,45 @@ fn test_rustapi_nest_includes_routes_in_openapi_spec() { ); } -use crate::health::{HealthCheckBuilder, HealthEndpointConfig, HealthStatus}; -use crate::ProductionDefaultsConfig; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::oneshot; - -fn bind_ephemeral_port() -> (u16, String) { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - drop(listener); - (port, format!("127.0.0.1:{port}")) +struct HotReloadEnvGuard { + previous: Option, } -#[tokio::test] -async fn run_with_shutdown_exposes_default_health_endpoints() { - let app = RustApi::new().health_endpoints(); - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; - let client = reqwest::Client::new(); - let base_url = format!("http://127.0.0.1:{port}"); - - for path in ["/health", "/ready", "/live"] { - let res = client - .get(format!("{base_url}{path}")) - .send() - .await - .expect("health endpoint request failed"); - assert_eq!(res.status(), 200, "{path} should return 200"); - let body: serde_json::Value = res.json().await.unwrap(); - assert!(body.get("status").is_some(), "{path} should include status"); - assert!( - body.get("timestamp").is_some(), - "{path} should include timestamp" - ); - } - - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; -} - -#[tokio::test] -async fn run_with_shutdown_returns_503_for_unhealthy_readiness() { - let health = HealthCheckBuilder::new(false) - .add_check("database", || async { - HealthStatus::unhealthy("database offline") - }) - .build(); - let app = RustApi::new().with_health_check(health); - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; - let res = reqwest::Client::new() - .get(format!("http://127.0.0.1:{port}/ready")) - .send() - .await - .expect("readiness endpoint request failed"); - assert_eq!(res.status(), 503); - assert!(res.text().await.unwrap().contains("database offline")); - - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; -} - -#[tokio::test] -async fn run_with_shutdown_honors_custom_health_paths() { - let config = HealthEndpointConfig::new() - .health_path("/healthz") - .readiness_path("/readyz") - .liveness_path("/livez"); - let app = RustApi::new().health_endpoints_with_config(config); - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; - let client = reqwest::Client::new(); - let base_url = format!("http://127.0.0.1:{port}"); - - for path in ["/healthz", "/readyz", "/livez"] { - let res = client - .get(format!("{base_url}{path}")) - .send() - .await - .expect("custom health path request failed"); - assert_eq!(res.status(), 200, "{path} should return 200"); - } - - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; -} - -#[tokio::test] -async fn run_with_shutdown_serves_status_page_and_tracks_requests() { - async fn task_handler() -> &'static str { - "ok" - } - - let app = RustApi::new() - .status_page() - .route("/task", get(task_handler)); - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; - let client = reqwest::Client::new(); - let base_url = format!("http://127.0.0.1:{port}"); - - let res = client - .get(format!("{base_url}/status")) - .send() - .await - .expect("status page request failed"); - assert_eq!(res.status(), 200); - let body = res.text().await.unwrap(); - assert!(body.contains("System Status")); - assert!(body.contains("Total Requests")); - - for _ in 0..2 { - let res = client - .get(format!("{base_url}/task")) - .send() - .await - .expect("task request failed"); - assert_eq!(res.status(), 200); +impl HotReloadEnvGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var("RUSTAPI_HOT_RELOAD").ok(); + match value { + Some(v) => std::env::set_var("RUSTAPI_HOT_RELOAD", v), + None => std::env::remove_var("RUSTAPI_HOT_RELOAD"), + } + Self { previous } } - - let res = client - .get(format!("{base_url}/status")) - .send() - .await - .expect("updated status page request failed"); - let body = res.text().await.unwrap(); - assert!(body.contains("Total Requests")); - - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; } -#[tokio::test] -async fn run_with_shutdown_runs_lifecycle_hooks() { - let on_start = Arc::new(AtomicBool::new(false)); - let on_shutdown = Arc::new(AtomicBool::new(false)); - let on_start_flag = on_start.clone(); - let on_shutdown_flag = on_shutdown.clone(); - - let app = RustApi::new() - .health_endpoints() - .on_start(move || { - let on_start_flag = on_start_flag.clone(); - async move { - on_start_flag.store(true, Ordering::SeqCst); - } - }) - .on_shutdown(move || { - let on_shutdown_flag = on_shutdown_flag.clone(); - async move { - on_shutdown_flag.store(true, Ordering::SeqCst); - } - }); - - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; - assert!( - on_start.load(Ordering::SeqCst), - "on_start should run via prepare_for_serve before accept loop" - ); - - let res = reqwest::Client::new() - .get(format!("http://127.0.0.1:{port}/health")) - .send() - .await - .expect("health probe failed"); - assert_eq!(res.status(), 200); - - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; - assert!( - on_shutdown.load(Ordering::SeqCst), - "on_shutdown should run after shutdown signal" - ); -} - -#[tokio::test] -async fn run_with_shutdown_applies_production_defaults() { - async fn hello() -> &'static str { - "ok" +impl Drop for HotReloadEnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var("RUSTAPI_HOT_RELOAD", value), + None => std::env::remove_var("RUSTAPI_HOT_RELOAD"), + } } - - let app = RustApi::new() - .production_defaults("users-api") - .route("/hello", get(hello)); - assert_eq!(app.layers().len(), 2); - - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; - let client = reqwest::Client::new(); - let base_url = format!("http://127.0.0.1:{port}"); - - let res = client - .get(format!("{base_url}/hello")) - .send() - .await - .expect("hello request failed"); - assert_eq!(res.status(), 200); - assert!(res.headers().get("x-request-id").is_some()); - - let res = client - .get(format!("{base_url}/health")) - .send() - .await - .expect("health request failed"); - assert_eq!(res.status(), 200); - let body: serde_json::Value = res.json().await.unwrap(); - assert!(body.get("version").is_none()); - assert!(body.get("checks").is_some()); - - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; } -#[tokio::test] -async fn run_with_shutdown_applies_custom_production_defaults() { - let config = ProductionDefaultsConfig::new("billing-api") - .version("1.2.3") - .health_endpoint_config( - HealthEndpointConfig::new() - .health_path("/healthz") - .readiness_path("/readyz") - .liveness_path("/livez"), - ); - - let app = RustApi::new().production_defaults_with_config(config); - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; - let client = reqwest::Client::new(); - let base_url = format!("http://127.0.0.1:{port}"); - - for path in ["/healthz", "/readyz", "/livez"] { - let res = client - .get(format!("{base_url}{path}")) - .send() - .await - .expect("probe request failed"); - assert_eq!(res.status(), 200); - } - - let res = client - .get(format!("{base_url}/healthz")) - .send() - .await - .expect("healthz request failed"); - let body: serde_json::Value = res.json().await.unwrap(); +#[test] +fn print_hot_reload_banner_selects_branch_from_preexisting_env() { + let _guard = HotReloadEnvGuard::set(None); + let app = RustApi::new().hot_reload(true); assert_eq!( - body.get("version"), - Some(&serde_json::Value::String("1.2.3".to_string())) + app.print_hot_reload_banner("127.0.0.1:8080"), + Some(false), + "tip branch when watcher env unset" ); - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; -} - -#[tokio::test] -async fn run_with_shutdown_serves_registered_routes() { - async fn ping() -> &'static str { - "pong" - } - - let app = RustApi::new().route("/ping", get(ping)); - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; - let res = reqwest::Client::new() - .get(format!("http://127.0.0.1:{port}/ping")) - .send() - .await - .expect("ping request failed"); - assert_eq!(res.status(), 200); - assert_eq!(res.text().await.unwrap(), "pong"); - - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; -} - -#[tokio::test] -async fn run_with_shutdown_enables_hot_reload_pipeline() { - struct HotReloadEnvGuard(Option); - - impl HotReloadEnvGuard { - fn clear() -> Self { - let previous = std::env::var("RUSTAPI_HOT_RELOAD").ok(); - std::env::remove_var("RUSTAPI_HOT_RELOAD"); - Self(previous) - } - } - - impl Drop for HotReloadEnvGuard { - fn drop(&mut self) { - match &self.0 { - Some(value) => std::env::set_var("RUSTAPI_HOT_RELOAD", value), - None => std::env::remove_var("RUSTAPI_HOT_RELOAD"), - } - } - } - - let _guard = HotReloadEnvGuard::clear(); - async fn ok_handler() -> &'static str { - "ok" - } - let app = RustApi::new() - .hot_reload(true) - .route("/ping", get(ok_handler)); - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; + let _guard = HotReloadEnvGuard::set(Some("1")); + let app = RustApi::new().hot_reload(true); assert_eq!( - std::env::var("RUSTAPI_HOT_RELOAD").ok().as_deref(), - Some("1"), - "prepare_for_serve should set hot-reload env via run entrypoint" - ); - - let res = reqwest::Client::new() - .get(format!("http://127.0.0.1:{port}/ping")) - .send() - .await - .expect("ping request failed"); - assert_eq!(res.status(), 200); - - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; -} - -#[tokio::test] -async fn run_with_shutdown_composes_health_and_status_endpoints() { - let app = RustApi::new().health_endpoints().status_page(); - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; - let client = reqwest::Client::new(); - let base_url = format!("http://127.0.0.1:{port}"); - - for path in ["/health", "/status"] { - let res = client - .get(format!("{base_url}{path}")) - .send() - .await - .unwrap_or_else(|_| panic!("request to {path} failed")); - assert_eq!(res.status(), 200, "{path} should be reachable"); - } - - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; -} - -#[tokio::test] -async fn run_with_shutdown_serves_nested_routes() { - async fn nested() -> &'static str { - "nested-ok" - } - - let app = RustApi::new().nest("/api", Router::new().route("/items", get(nested))); - let (port, addr) = bind_ephemeral_port(); - let (tx, rx) = oneshot::channel(); - - let server = tokio::spawn(async move { - app.run_with_shutdown(&addr, async { - rx.await.ok(); - }) - .await - }); - - tokio::time::sleep(Duration::from_millis(200)).await; - let res = reqwest::Client::new() - .get(format!("http://127.0.0.1:{port}/api/items")) - .send() - .await - .expect("nested route request failed"); - assert_eq!(res.status(), 200); - assert_eq!(res.text().await.unwrap(), "nested-ok"); - - tx.send(()).unwrap(); - let _ = tokio::time::timeout(Duration::from_secs(2), server).await; -} - -#[test] -fn production_defaults_can_disable_optional_layers() { - let app = RustApi::new().production_defaults_with_config( - ProductionDefaultsConfig::new("minimal-api") - .request_id(false) - .tracing(false) - .health_endpoints(false), + app.print_hot_reload_banner("127.0.0.1:8081"), + Some(true), + "watcher branch when env already active" ); - - assert_eq!(app.layers().len(), 0); } diff --git a/crates/rustapi-core/src/extract.rs b/crates/rustapi-core/src/extract.rs index 192a00d..606823b 100644 --- a/crates/rustapi-core/src/extract.rs +++ b/crates/rustapi-core/src/extract.rs @@ -1426,3 +1426,11 @@ impl FromRequestParts for CursorPaginate { )) } } + +#[cfg(test)] +mod tests { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/support/extract_lib.rs" + )); +} diff --git a/crates/rustapi-core/src/router/mod.rs b/crates/rustapi-core/src/router/mod.rs index f6cc5cf..ff25256 100644 --- a/crates/rustapi-core/src/router/mod.rs +++ b/crates/rustapi-core/src/router/mod.rs @@ -8,11 +8,16 @@ mod core; mod match_; mod method_router; -#[cfg(test)] -mod tests; - pub use core::Router; pub use match_::RouteMatch; #[cfg(test)] pub(crate) use match_::{convert_path_params, normalize_path_for_comparison, normalize_prefix}; pub use method_router::{delete, get, patch, post, put, MethodRouter}; + +#[cfg(test)] +mod tests { + include!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/support/router_lib.rs" + )); +} diff --git a/crates/rustapi-core/src/router/tests/mod.rs b/crates/rustapi-core/src/router/tests/mod.rs deleted file mode 100644 index 3a0e6cb..0000000 --- a/crates/rustapi-core/src/router/tests/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod property_early; -mod property_late; -mod unit; diff --git a/crates/rustapi-core/src/router/tests/property_early.rs b/crates/rustapi-core/src/router/tests/property_early.rs deleted file mode 100644 index 411cb80..0000000 --- a/crates/rustapi-core/src/router/tests/property_early.rs +++ /dev/null @@ -1,780 +0,0 @@ -use crate::router::{ - convert_path_params, delete, get, normalize_path_for_comparison, normalize_prefix, patch, post, - put, MethodRouter, RouteMatch, Router, -}; -use http::Method; -use proptest::prelude::*; -use std::panic::{catch_unwind, AssertUnwindSafe}; - -// **Feature: router-nesting, Property 2: Prefix Normalization** -// -// For any prefix string (with or without leading/trailing slashes), the normalized -// prefix should start with exactly one slash and have no trailing slash, and all -// nested routes should have properly formed paths without double slashes. -// -// **Validates: Requirements 1.2, 1.3** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Normalized prefix always starts with exactly one slash - /// - /// For any input prefix, the normalized result should always start with - /// exactly one leading slash. - #[test] - fn prop_normalized_prefix_starts_with_single_slash( - // Generate prefix with optional leading slashes - leading_slashes in prop::collection::vec(Just('/'), 0..5), - segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 0..4), - trailing_slashes in prop::collection::vec(Just('/'), 0..5), - ) { - // Build the input prefix - let mut prefix = String::new(); - for _ in &leading_slashes { - prefix.push('/'); - } - for (i, segment) in segments.iter().enumerate() { - if i > 0 { - prefix.push('/'); - } - prefix.push_str(segment); - } - for _ in &trailing_slashes { - prefix.push('/'); - } - - let normalized = normalize_prefix(&prefix); - - // Property 1: Always starts with exactly one slash - prop_assert!( - normalized.starts_with('/'), - "Normalized prefix '{}' should start with '/', input was '{}'", - normalized, prefix - ); - - // Property 2: No double slashes at the start - prop_assert!( - !normalized.starts_with("//"), - "Normalized prefix '{}' should not start with '//', input was '{}'", - normalized, prefix - ); - } - - /// Property: Normalized prefix has no trailing slash (unless root) - /// - /// For any input prefix with non-empty segments, the normalized result - /// should have no trailing slash. - #[test] - fn prop_normalized_prefix_no_trailing_slash( - segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4), - trailing_slashes in prop::collection::vec(Just('/'), 0..5), - ) { - // Build the input prefix with segments - let mut prefix = String::from("/"); - for (i, segment) in segments.iter().enumerate() { - if i > 0 { - prefix.push('/'); - } - prefix.push_str(segment); - } - for _ in &trailing_slashes { - prefix.push('/'); - } - - let normalized = normalize_prefix(&prefix); - - // Property: No trailing slash when there are segments - prop_assert!( - !normalized.ends_with('/'), - "Normalized prefix '{}' should not end with '/', input was '{}'", - normalized, prefix - ); - } - - /// Property: Normalized prefix has no double slashes - /// - /// For any input prefix, the normalized result should never contain - /// consecutive slashes. - #[test] - fn prop_normalized_prefix_no_double_slashes( - // Generate prefix with random slashes between segments - segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4), - extra_slashes in prop::collection::vec(0..4usize, 1..4), - ) { - // Build the input prefix with extra slashes between segments - let mut prefix = String::from("/"); - for (i, segment) in segments.iter().enumerate() { - if i > 0 { - // Add extra slashes between segments - let num_slashes = extra_slashes.get(i).copied().unwrap_or(1); - for _ in 0..=num_slashes { - prefix.push('/'); - } - } - prefix.push_str(segment); - } - - let normalized = normalize_prefix(&prefix); - - // Property: No consecutive slashes - prop_assert!( - !normalized.contains("//"), - "Normalized prefix '{}' should not contain '//', input was '{}'", - normalized, prefix - ); - } - - /// Property: Prefix normalization preserves segment content - /// - /// For any input prefix, all non-empty segments should be preserved - /// in the normalized output in the same order. - #[test] - fn prop_normalized_prefix_preserves_segments( - segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..4), - ) { - // Build the input prefix - let prefix = format!("/{}", segments.join("/")); - - let normalized = normalize_prefix(&prefix); - - // Extract segments from normalized prefix - let normalized_segments: Vec<&str> = normalized - .split('/') - .filter(|s| !s.is_empty()) - .collect(); - - prop_assert_eq!( - segments.len(), - normalized_segments.len(), - "Segment count should be preserved" - ); - - for (original, normalized_seg) in segments.iter().zip(normalized_segments.iter()) { - prop_assert_eq!( - original, normalized_seg, - "Segment content should be preserved" - ); - } - } - - /// Property: Empty or slash-only input normalizes to root - /// - /// For any input that contains only slashes or is empty, the normalized - /// result should be exactly "/". - #[test] - fn prop_empty_or_slashes_normalize_to_root( - num_slashes in 0..10usize, - ) { - let prefix = "/".repeat(num_slashes); - - let normalized = normalize_prefix(&prefix); - - prop_assert_eq!( - normalized, "/", - "Empty or slash-only prefix '{}' should normalize to '/'", - prefix - ); - } -} - -// **Feature: router-nesting, Property 3: HTTP Method Preservation** -// -// For any router with routes having multiple HTTP methods, cloning the MethodRouter -// should preserve all method handlers for each route. -// -// **Validates: Requirements 1.4** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Cloning a MethodRouter preserves all HTTP method handlers - /// - /// For any combination of HTTP methods registered on a MethodRouter, - /// cloning should preserve all handlers and their associated methods. - #[test] - fn prop_method_router_clone_preserves_methods( - // Generate a random subset of HTTP methods to register - use_get in any::(), - use_post in any::(), - use_put in any::(), - use_patch in any::(), - use_delete in any::(), - ) { - // Ensure at least one method is selected - prop_assume!(use_get || use_post || use_put || use_patch || use_delete); - - // Build a MethodRouter with the selected methods - let mut method_router = MethodRouter::new(); - let mut expected_methods: Vec = Vec::new(); - - async fn handler() -> &'static str { "handler" } - - if use_get { - method_router = get(handler); - expected_methods.push(Method::GET); - } - - if use_post { - let post_router = post(handler); - for (method, handler) in post_router.handlers { - method_router.handlers.insert(method.clone(), handler); - if !expected_methods.contains(&method) { - expected_methods.push(method); - } - } - } - - if use_put { - let put_router = put(handler); - for (method, handler) in put_router.handlers { - method_router.handlers.insert(method.clone(), handler); - if !expected_methods.contains(&method) { - expected_methods.push(method); - } - } - } - - if use_patch { - let patch_router = patch(handler); - for (method, handler) in patch_router.handlers { - method_router.handlers.insert(method.clone(), handler); - if !expected_methods.contains(&method) { - expected_methods.push(method); - } - } - } - - if use_delete { - let delete_router = delete(handler); - for (method, handler) in delete_router.handlers { - method_router.handlers.insert(method.clone(), handler); - if !expected_methods.contains(&method) { - expected_methods.push(method); - } - } - } - - // Clone the MethodRouter - let cloned_router = method_router.clone(); - - // Verify all methods are preserved in the clone - let original_methods = method_router.allowed_methods(); - let cloned_methods = cloned_router.allowed_methods(); - - prop_assert_eq!( - original_methods.len(), - cloned_methods.len(), - "Cloned router should have same number of methods" - ); - - for method in &expected_methods { - prop_assert!( - cloned_router.get_handler(method).is_some(), - "Cloned router should have handler for method {:?}", - method - ); - } - - // Verify handlers are accessible (not null/invalid) - for method in &cloned_methods { - prop_assert!( - cloned_router.get_handler(method).is_some(), - "Handler for {:?} should be accessible after clone", - method - ); - } - } -} - -// **Feature: router-nesting, Property 1: Route Registration with Prefix** -// -// For any router with routes and any valid prefix, nesting the router should -// result in all routes being registered with the prefix prepended to their -// original paths. -// -// **Validates: Requirements 1.1** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: All nested routes are registered with prefix prepended - /// - /// For any router with routes and any valid prefix, nesting should result - /// in all routes being registered with the prefix prepended. - #[test] - fn prop_nested_routes_have_prefix( - // Generate prefix segments - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), - // Generate route path segments - route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), - has_param in any::(), - ) { - async fn handler() -> &'static str { "handler" } - - // Build the prefix - let prefix = format!("/{}", prefix_segments.join("/")); - - // Build the route path - let mut route_path = format!("/{}", route_segments.join("/")); - if has_param { - route_path.push_str("/{id}"); - } - - // Create nested router and nest it - let nested_router = Router::new().route(&route_path, get(handler)); - let app = Router::new().nest(&prefix, nested_router); - - // Build expected prefixed path (matchit format) - let expected_matchit_path = if has_param { - format!("{}/{}/:id", prefix, route_segments.join("/")) - } else { - format!("{}/{}", prefix, route_segments.join("/")) - }; - - let routes = app.registered_routes(); - - // Property: The prefixed route should exist - prop_assert!( - routes.contains_key(&expected_matchit_path), - "Expected route '{}' not found. Available routes: {:?}", - expected_matchit_path, - routes.keys().collect::>() - ); - - // Property: The route info should have the correct display path - let route_info = routes.get(&expected_matchit_path).unwrap(); - let expected_display_path = format!("{}{}", prefix, route_path); - prop_assert_eq!( - &route_info.path, &expected_display_path, - "Display path should be prefix + original path" - ); - } - - /// Property: Number of routes is preserved after nesting - /// - /// For any router with N routes, nesting should result in exactly N routes - /// being registered in the parent router (assuming no conflicts). - #[test] - fn prop_route_count_preserved_after_nesting( - // Generate number of routes (1-3 to keep test fast) - num_routes in 1..4usize, - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), - ) { - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix_segments.join("/")); - - // Create nested router with multiple routes - let mut nested_router = Router::new(); - for i in 0..num_routes { - let path = format!("/route{}", i); - nested_router = nested_router.route(&path, get(handler)); - } - - let app = Router::new().nest(&prefix, nested_router); - - prop_assert_eq!( - app.registered_routes().len(), - num_routes, - "Number of routes should be preserved after nesting" - ); - } - - /// Property: Nested routes are matchable - /// - /// For any nested route, a request to the prefixed path should match. - #[test] - fn prop_nested_routes_are_matchable( - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - ) { - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix_segments.join("/")); - let route_path = format!("/{}", route_segments.join("/")); - - let nested_router = Router::new().route(&route_path, get(handler)); - let app = Router::new().nest(&prefix, nested_router); - - // Build the full path to match - let full_path = format!("{}{}", prefix, route_path); - - // Property: The route should be matchable - match app.match_route(&full_path, &Method::GET) { - RouteMatch::Found { .. } => { - // Success - route was found - } - RouteMatch::NotFound => { - prop_assert!(false, "Route '{}' should be found but got NotFound", full_path); - } - RouteMatch::MethodNotAllowed { .. } => { - prop_assert!(false, "Route '{}' should be found but got MethodNotAllowed", full_path); - } - } - } -} - -// **Feature: router-nesting, Property 9: State Merging** -// -// For any nested router with state, that state should be accessible via the -// State extractor in handlers after nesting (assuming no type conflict with parent). -// -// **Validates: Requirements 3.1, 3.3** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: State type IDs are merged from nested router - /// - /// For any nested router with state, the parent router should track - /// the state type IDs after nesting. - #[test] - fn prop_state_type_ids_merged( - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - has_nested_state in any::(), - ) { - #[derive(Clone)] - struct TestState(#[allow(dead_code)] i32); - - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix_segments.join("/")); - - let mut nested = Router::new().route("/test", get(handler)); - if has_nested_state { - nested = nested.state(TestState(42)); - } - - let parent = Router::new().nest(&prefix, nested); - - // Property: If nested had state, parent should track the type ID - if has_nested_state { - prop_assert!( - parent.state_type_ids().contains(&std::any::TypeId::of::()), - "Parent should track nested state type ID" - ); - } - } - - /// Property: State merging adds nested state to parent - /// - /// For any nested router with state that the parent doesn't have, - /// merge_state should add that state to the parent. - #[test] - fn prop_merge_state_adds_nested_state( - state_value in any::(), - ) { - #[derive(Clone, PartialEq, Debug)] - struct UniqueState(i32); - - // Create a source router with state - let source = Router::new().state(UniqueState(state_value)); - - // Create a parent without this state type - let parent = Router::new().merge_state::(&source); - - // Property: Parent should now have the state - prop_assert!( - parent.has_state::(), - "Parent should have state after merge" - ); - - // Property: State value should match - let merged_state = parent.state.get::().unwrap(); - prop_assert_eq!( - merged_state.0, state_value, - "Merged state value should match source" - ); - } -} - -// **Feature: router-nesting, Property 10: State Precedence** -// -// For any parent and nested router both having state of the same type, -// the parent's state value should be preserved after nesting. -// -// **Validates: Requirements 3.2** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Parent state takes precedence over nested state - /// - /// For any parent and nested router both having state of the same type, - /// the parent's state value should be preserved after merge_state. - #[test] - fn prop_parent_state_takes_precedence( - parent_value in any::(), - nested_value in any::(), - ) { - // Ensure values are different to make the test meaningful - prop_assume!(parent_value != nested_value); - - #[derive(Clone, PartialEq, Debug)] - struct SharedState(i32); - - // Create source router with nested state - let source = Router::new().state(SharedState(nested_value)); - - // Create parent with its own state - let parent = Router::new() - .state(SharedState(parent_value)) - .merge_state::(&source); - - // Property: Parent should still have state - prop_assert!( - parent.has_state::(), - "Parent should have state" - ); - - // Property: Parent's state value should be preserved (parent wins) - let final_state = parent.state.get::().unwrap(); - prop_assert_eq!( - final_state.0, parent_value, - "Parent state value should be preserved, not overwritten by nested" - ); - } - - /// Property: State precedence is consistent regardless of merge order - /// - /// For any parent with state, merging from a source with the same type - /// should always preserve the parent's value. - #[test] - fn prop_state_precedence_consistent( - parent_value in any::(), - source1_value in any::(), - source2_value in any::(), - ) { - #[derive(Clone, PartialEq, Debug)] - struct ConsistentState(i32); - - // Create multiple source routers - let source1 = Router::new().state(ConsistentState(source1_value)); - let source2 = Router::new().state(ConsistentState(source2_value)); - - // Create parent with its own state and merge from multiple sources - let parent = Router::new() - .state(ConsistentState(parent_value)) - .merge_state::(&source1) - .merge_state::(&source2); - - // Property: Parent's original state should be preserved - let final_state = parent.state.get::().unwrap(); - prop_assert_eq!( - final_state.0, parent_value, - "Parent state should be preserved after multiple merges" - ); - } -} - -// **Feature: phase4-ergonomics-v1, Property 1: Route Conflict Detection** -// -// For any two routes with the same path and HTTP method registered on the same -// RustApi instance, the system should detect the conflict and report an error -// at startup time. -// -// **Validates: Requirements 1.2** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Routes with identical path structure but different parameter names conflict - /// - /// For any valid path with parameters, registering two routes with the same - /// structure but different parameter names should be detected as a conflict. - #[test] - fn prop_same_structure_different_param_names_conflict( - // Generate valid path segments - segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4), - // Generate two different parameter names - param1 in "[a-z][a-z0-9]{0,5}", - param2 in "[a-z][a-z0-9]{0,5}", - ) { - // Ensure param names are different - prop_assume!(param1 != param2); - - // Build two paths with same structure but different param names - let mut path1 = String::from("/"); - let mut path2 = String::from("/"); - - for segment in &segments { - path1.push_str(segment); - path1.push('/'); - path2.push_str(segment); - path2.push('/'); - } - - path1.push('{'); - path1.push_str(¶m1); - path1.push('}'); - - path2.push('{'); - path2.push_str(¶m2); - path2.push('}'); - - // Try to register both routes - should panic - let result = catch_unwind(AssertUnwindSafe(|| { - async fn handler1() -> &'static str { "handler1" } - async fn handler2() -> &'static str { "handler2" } - - let _router = Router::new() - .route(&path1, get(handler1)) - .route(&path2, get(handler2)); - })); - - prop_assert!( - result.is_err(), - "Routes '{}' and '{}' should conflict but didn't", - path1, path2 - ); - } - - /// Property: Routes with different path structures don't conflict - /// - /// For any two paths with different structures (different number of segments - /// or different static segments), they should not conflict. - #[test] - fn prop_different_structures_no_conflict( - // Generate different path segments for two routes - segments1 in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), - segments2 in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), - // Optional parameter at the end - has_param1 in any::(), - has_param2 in any::(), - ) { - // Build two paths - let mut path1 = String::from("/"); - let mut path2 = String::from("/"); - - for segment in &segments1 { - path1.push_str(segment); - path1.push('/'); - } - path1.pop(); // Remove trailing slash - - for segment in &segments2 { - path2.push_str(segment); - path2.push('/'); - } - path2.pop(); // Remove trailing slash - - if has_param1 { - path1.push_str("/{id}"); - } - - if has_param2 { - path2.push_str("/{id}"); - } - - // Normalize paths for comparison - let norm1 = normalize_path_for_comparison(&convert_path_params(&path1)); - let norm2 = normalize_path_for_comparison(&convert_path_params(&path2)); - - // Only test if paths are actually different - prop_assume!(norm1 != norm2); - - // Try to register both routes - should succeed - let result = catch_unwind(AssertUnwindSafe(|| { - async fn handler1() -> &'static str { "handler1" } - async fn handler2() -> &'static str { "handler2" } - - let router = Router::new() - .route(&path1, get(handler1)) - .route(&path2, get(handler2)); - - router.registered_routes().len() - })); - - prop_assert!( - result.is_ok(), - "Routes '{}' and '{}' should not conflict but did", - path1, path2 - ); - - if let Ok(count) = result { - prop_assert_eq!(count, 2, "Should have registered 2 routes"); - } - } - - /// Property: Conflict error message contains both route paths - /// - /// When a conflict is detected, the error message should include both - /// the existing route path and the new conflicting route path. - #[test] - fn prop_conflict_error_contains_both_paths( - // Generate a valid path segment - segment in "[a-z][a-z0-9]{1,5}", - param1 in "[a-z][a-z0-9]{1,5}", - param2 in "[a-z][a-z0-9]{1,5}", - ) { - prop_assume!(param1 != param2); - - let path1 = format!("/{}/{{{}}}", segment, param1); - let path2 = format!("/{}/{{{}}}", segment, param2); - - let result = catch_unwind(AssertUnwindSafe(|| { - async fn handler1() -> &'static str { "handler1" } - async fn handler2() -> &'static str { "handler2" } - - let _router = Router::new() - .route(&path1, get(handler1)) - .route(&path2, get(handler2)); - })); - - prop_assert!(result.is_err(), "Should have panicked due to conflict"); - - // Check that the panic message contains useful information - if let Err(panic_info) = result { - if let Some(msg) = panic_info.downcast_ref::() { - prop_assert!( - msg.contains("ROUTE CONFLICT DETECTED"), - "Error should contain 'ROUTE CONFLICT DETECTED', got: {}", - msg - ); - prop_assert!( - msg.contains("Existing:") && msg.contains("New:"), - "Error should contain both 'Existing:' and 'New:' labels, got: {}", - msg - ); - prop_assert!( - msg.contains("How to resolve:"), - "Error should contain resolution guidance, got: {}", - msg - ); - } - } - } - - /// Property: Exact duplicate paths conflict - /// - /// Registering the exact same path twice should always be detected as a conflict. - #[test] - fn prop_exact_duplicate_paths_conflict( - // Generate valid path segments - segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4), - has_param in any::(), - ) { - // Build a path - let mut path = String::from("/"); - - for segment in &segments { - path.push_str(segment); - path.push('/'); - } - path.pop(); // Remove trailing slash - - if has_param { - path.push_str("/{id}"); - } - - // Try to register the same path twice - should panic - let result = catch_unwind(AssertUnwindSafe(|| { - async fn handler1() -> &'static str { "handler1" } - async fn handler2() -> &'static str { "handler2" } - - let _router = Router::new() - .route(&path, get(handler1)) - .route(&path, get(handler2)); - })); - - prop_assert!( - result.is_err(), - "Registering path '{}' twice should conflict but didn't", - path - ); - } -} diff --git a/crates/rustapi-core/src/router/tests/property_late.rs b/crates/rustapi-core/src/router/tests/property_late.rs deleted file mode 100644 index 3c74a49..0000000 --- a/crates/rustapi-core/src/router/tests/property_late.rs +++ /dev/null @@ -1,1066 +0,0 @@ -use crate::router::{convert_path_params, get, post, put, MethodRouter, RouteMatch, Router}; -use http::Method; -use proptest::prelude::*; -use std::panic::{catch_unwind, AssertUnwindSafe}; - -// **Feature: router-nesting, Property 5: Nested Route Matching** -// -// For any nested route and a request with a matching path and method, -// the router should return the correct handler. -// -// **Validates: Requirements 2.1** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Nested routes with path parameters are correctly matched - /// - /// For any nested route with path parameters, a request to the prefixed path - /// with valid parameter values should match and return Found. - #[test] - fn prop_nested_route_with_params_matches( - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 0..2), - param_value in "[a-z0-9]{1,10}", - ) { - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix_segments.join("/")); - let route_path = if route_segments.is_empty() { - "/{id}".to_string() - } else { - format!("/{}/{{id}}", route_segments.join("/")) - }; - - let nested_router = Router::new().route(&route_path, get(handler)); - let app = Router::new().nest(&prefix, nested_router); - - // Build the full path to match with actual parameter value - let full_path = if route_segments.is_empty() { - format!("{}/{}", prefix, param_value) - } else { - format!("{}/{}/{}", prefix, route_segments.join("/"), param_value) - }; - - // Property: The route should be matched - match app.match_route(&full_path, &Method::GET) { - RouteMatch::Found { params, .. } => { - // Verify the parameter was extracted - prop_assert!( - params.contains_key("id"), - "Should have 'id' parameter, got: {:?}", - params - ); - prop_assert_eq!( - params.get("id").unwrap(), - ¶m_value, - "Parameter value should match" - ); - } - RouteMatch::NotFound => { - prop_assert!(false, "Route '{}' should be found but got NotFound", full_path); - } - RouteMatch::MethodNotAllowed { .. } => { - prop_assert!(false, "Route '{}' should be found but got MethodNotAllowed", full_path); - } - } - } - - /// Property: Nested routes match correct HTTP method - /// - /// For any nested route registered with a specific HTTP method, only requests - /// with that method should return Found. - #[test] - fn prop_nested_route_matches_correct_method( - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2), - route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2), - use_get in any::(), - ) { - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix_segments.join("/")); - let route_path = format!("/{}", route_segments.join("/")); - - // Register with either GET or POST - let method_router = if use_get { get(handler) } else { post(handler) }; - let nested_router = Router::new().route(&route_path, method_router); - let app = Router::new().nest(&prefix, nested_router); - - let full_path = format!("{}{}", prefix, route_path); - let registered_method = if use_get { Method::GET } else { Method::POST }; - let other_method = if use_get { Method::POST } else { Method::GET }; - - // Property: Registered method should match - match app.match_route(&full_path, ®istered_method) { - RouteMatch::Found { .. } => { - // Success - } - other => { - prop_assert!(false, "Route should be found for registered method, got: {:?}", - match other { - RouteMatch::NotFound => "NotFound", - RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed", - _ => "Found", - } - ); - } - } - - // Property: Other method should return MethodNotAllowed - match app.match_route(&full_path, &other_method) { - RouteMatch::MethodNotAllowed { allowed } => { - prop_assert!( - allowed.contains(®istered_method), - "Allowed methods should contain {:?}", - registered_method - ); - } - other => { - prop_assert!(false, "Route should return MethodNotAllowed for other method, got: {:?}", - match other { - RouteMatch::NotFound => "NotFound", - RouteMatch::Found { .. } => "Found", - _ => "MethodNotAllowed", - } - ); - } - } - } -} - -// **Feature: router-nesting, Property 6: Path Parameter Extraction** -// -// For any nested route with path parameters and a matching request, -// the extracted parameters should have the correct names and values. -// -// **Validates: Requirements 2.2** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Single path parameter is correctly extracted from nested route - /// - /// For any nested route with a single path parameter, the parameter name - /// and value should be correctly extracted. - #[test] - fn prop_single_param_extraction( - prefix in "[a-z][a-z0-9]{1,5}", - param_name in "[a-z][a-z0-9]{1,5}", - param_value in "[a-z0-9]{1,10}", - ) { - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix); - let route_path = format!("/{{{}}}", param_name); - - let nested_router = Router::new().route(&route_path, get(handler)); - let app = Router::new().nest(&prefix, nested_router); - - let full_path = format!("{}/{}", prefix, param_value); - - match app.match_route(&full_path, &Method::GET) { - RouteMatch::Found { params, .. } => { - prop_assert!( - params.contains_key(¶m_name), - "Should have '{}' parameter, got: {:?}", - param_name, params - ); - prop_assert_eq!( - params.get(¶m_name).unwrap(), - ¶m_value, - "Parameter '{}' value should be '{}'", - param_name, param_value - ); - } - _ => { - prop_assert!(false, "Route should be found"); - } - } - } - - /// Property: Multiple path parameters are correctly extracted from nested route - /// - /// For any nested route with multiple path parameters, all parameters - /// should be correctly extracted with their names and values. - #[test] - fn prop_multiple_params_extraction( - prefix in "[a-z][a-z0-9]{1,5}", - param1_name in "[a-z]{1,5}", - param1_value in "[a-z0-9]{1,10}", - param2_name in "[a-z]{1,5}", - param2_value in "[a-z0-9]{1,10}", - ) { - // Ensure param names are different - prop_assume!(param1_name != param2_name); - - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix); - let route_path = format!("/{{{}}}/items/{{{}}}", param1_name, param2_name); - - let nested_router = Router::new().route(&route_path, get(handler)); - let app = Router::new().nest(&prefix, nested_router); - - let full_path = format!("{}/{}/items/{}", prefix, param1_value, param2_value); - - match app.match_route(&full_path, &Method::GET) { - RouteMatch::Found { params, .. } => { - // Check first parameter - prop_assert!( - params.contains_key(¶m1_name), - "Should have '{}' parameter, got: {:?}", - param1_name, params - ); - prop_assert_eq!( - params.get(¶m1_name).unwrap(), - ¶m1_value, - "Parameter '{}' value should be '{}'", - param1_name, param1_value - ); - - // Check second parameter - prop_assert!( - params.contains_key(¶m2_name), - "Should have '{}' parameter, got: {:?}", - param2_name, params - ); - prop_assert_eq!( - params.get(¶m2_name).unwrap(), - ¶m2_value, - "Parameter '{}' value should be '{}'", - param2_name, param2_value - ); - } - _ => { - prop_assert!(false, "Route should be found"); - } - } - } - - /// Property: Path parameters preserve special characters in values - /// - /// For any nested route with path parameters, parameter values containing - /// URL-safe special characters should be preserved correctly. - #[test] - fn prop_param_value_preservation( - prefix in "[a-z]{1,5}", - // Generate values with alphanumeric and some special chars - param_value in "[a-zA-Z0-9_-]{1,15}", - ) { - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix); - let route_path = "/{id}".to_string(); - - let nested_router = Router::new().route(&route_path, get(handler)); - let app = Router::new().nest(&prefix, nested_router); - - let full_path = format!("{}/{}", prefix, param_value); - - match app.match_route(&full_path, &Method::GET) { - RouteMatch::Found { params, .. } => { - prop_assert_eq!( - params.get("id").unwrap(), - ¶m_value, - "Parameter value should be preserved exactly" - ); - } - _ => { - prop_assert!(false, "Route should be found"); - } - } - } -} - -// **Feature: router-nesting, Property 7: Not Found Response** -// -// For any request path that doesn't match any registered route (nested or otherwise), -// the router should return NotFound. -// -// **Validates: Requirements 2.3** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Unregistered paths return NotFound - /// - /// For any path that doesn't match any registered route, the router - /// should return NotFound. - #[test] - fn prop_unregistered_path_returns_not_found( - prefix in "[a-z][a-z0-9]{1,5}", - route_segment in "[a-z][a-z0-9]{1,5}", - unregistered_segment in "[a-z][a-z0-9]{6,10}", - ) { - // Ensure segments are different - prop_assume!(route_segment != unregistered_segment); - - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix); - let route_path = format!("/{}", route_segment); - - let nested_router = Router::new().route(&route_path, get(handler)); - let app = Router::new().nest(&prefix, nested_router); - - // Try to match an unregistered path - let unregistered_path = format!("{}/{}", prefix, unregistered_segment); - - match app.match_route(&unregistered_path, &Method::GET) { - RouteMatch::NotFound => { - // Success - this is expected - } - RouteMatch::Found { .. } => { - prop_assert!(false, "Path '{}' should not be found", unregistered_path); - } - RouteMatch::MethodNotAllowed { .. } => { - prop_assert!(false, "Path '{}' should return NotFound, not MethodNotAllowed", unregistered_path); - } - } - } - - /// Property: Wrong prefix returns NotFound - /// - /// For any nested route, a request with a different prefix should return NotFound. - #[test] - fn prop_wrong_prefix_returns_not_found( - prefix1 in "[a-z][a-z0-9]{1,5}", - prefix2 in "[a-z][a-z0-9]{6,10}", - route_segment in "[a-z][a-z0-9]{1,5}", - ) { - // Ensure prefixes are different - prop_assume!(prefix1 != prefix2); - - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix1); - let route_path = format!("/{}", route_segment); - - let nested_router = Router::new().route(&route_path, get(handler)); - let app = Router::new().nest(&prefix, nested_router); - - // Try to match with wrong prefix - let wrong_prefix_path = format!("/{}/{}", prefix2, route_segment); - - match app.match_route(&wrong_prefix_path, &Method::GET) { - RouteMatch::NotFound => { - // Success - this is expected - } - _ => { - prop_assert!(false, "Path '{}' with wrong prefix should return NotFound", wrong_prefix_path); - } - } - } - - /// Property: Partial path match returns NotFound - /// - /// For any nested route with multiple segments, a request matching only - /// part of the path should return NotFound. - #[test] - fn prop_partial_path_returns_not_found( - prefix in "[a-z][a-z0-9]{1,5}", - segment1 in "[a-z][a-z0-9]{1,5}", - segment2 in "[a-z][a-z0-9]{1,5}", - ) { - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix); - let route_path = format!("/{}/{}", segment1, segment2); - - let nested_router = Router::new().route(&route_path, get(handler)); - let app = Router::new().nest(&prefix, nested_router); - - // Try to match only the first segment (partial path) - let partial_path = format!("{}/{}", prefix, segment1); - - match app.match_route(&partial_path, &Method::GET) { - RouteMatch::NotFound => { - // Success - partial path should not match - } - _ => { - prop_assert!(false, "Partial path '{}' should return NotFound", partial_path); - } - } - } -} - -// **Feature: router-nesting, Property 8: Method Not Allowed Response** -// -// For any request to a valid path but with an unregistered HTTP method, -// the router should return MethodNotAllowed with the list of allowed methods. -// -// **Validates: Requirements 2.4** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Unregistered method returns MethodNotAllowed with allowed methods - /// - /// For any nested route registered with specific methods, a request with - /// an unregistered method should return MethodNotAllowed with the correct - /// list of allowed methods. - #[test] - fn prop_unregistered_method_returns_method_not_allowed( - prefix in "[a-z][a-z0-9]{1,5}", - route_segment in "[a-z][a-z0-9]{1,5}", - ) { - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix); - let route_path = format!("/{}", route_segment); - - // Register only GET - let nested_router = Router::new().route(&route_path, get(handler)); - let app = Router::new().nest(&prefix, nested_router); - - let full_path = format!("{}{}", prefix, route_path); - - // Try POST on a GET-only route - match app.match_route(&full_path, &Method::POST) { - RouteMatch::MethodNotAllowed { allowed } => { - prop_assert!( - allowed.contains(&Method::GET), - "Allowed methods should contain GET, got: {:?}", - allowed - ); - prop_assert!( - !allowed.contains(&Method::POST), - "Allowed methods should not contain POST" - ); - } - RouteMatch::Found { .. } => { - prop_assert!(false, "POST should not be found on GET-only route"); - } - RouteMatch::NotFound => { - prop_assert!(false, "Path exists, should return MethodNotAllowed not NotFound"); - } - } - } - - /// Property: Multiple registered methods are all returned in allowed list - /// - /// For any nested route registered with multiple methods, the MethodNotAllowed - /// response should include all registered methods. - #[test] - fn prop_multiple_methods_in_allowed_list( - prefix in "[a-z][a-z0-9]{1,5}", - route_segment in "[a-z][a-z0-9]{1,5}", - use_get in any::(), - use_post in any::(), - use_put in any::(), - ) { - // Ensure at least one method is registered - prop_assume!(use_get || use_post || use_put); - - async fn handler() -> &'static str { "handler" } - - let prefix = format!("/{}", prefix); - let route_path = format!("/{}", route_segment); - - // Build method router with selected methods - let mut method_router = MethodRouter::new(); - let mut expected_methods: Vec = Vec::new(); - - if use_get { - let get_router = get(handler); - for (method, h) in get_router.handlers { - method_router.handlers.insert(method.clone(), h); - expected_methods.push(method); - } - } - if use_post { - let post_router = post(handler); - for (method, h) in post_router.handlers { - method_router.handlers.insert(method.clone(), h); - expected_methods.push(method); - } - } - if use_put { - let put_router = put(handler); - for (method, h) in put_router.handlers { - method_router.handlers.insert(method.clone(), h); - expected_methods.push(method); - } - } - - let nested_router = Router::new().route(&route_path, method_router); - let app = Router::new().nest(&prefix, nested_router); - - let full_path = format!("{}{}", prefix, route_path); - - // Try DELETE (which we never register) - match app.match_route(&full_path, &Method::DELETE) { - RouteMatch::MethodNotAllowed { allowed } => { - // All registered methods should be in allowed list - for method in &expected_methods { - prop_assert!( - allowed.contains(method), - "Allowed methods should contain {:?}, got: {:?}", - method, allowed - ); - } - // DELETE should not be in allowed list - prop_assert!( - !allowed.contains(&Method::DELETE), - "Allowed methods should not contain DELETE" - ); - } - RouteMatch::Found { .. } => { - prop_assert!(false, "DELETE should not be found"); - } - RouteMatch::NotFound => { - prop_assert!(false, "Path exists, should return MethodNotAllowed not NotFound"); - } - } - } -} - -// **Feature: router-nesting, Property 12: Conflict Detection** -// -// For any nested route that conflicts with an existing route (same path structure), -// the router should detect and report the conflict with both route paths. -// -// **Validates: Requirements 5.1, 5.3** - -// **Feature: router-nesting, Property 4: Multiple Router Composition** -// -// For any set of routers with non-overlapping route structures nested under -// different prefixes, all routes should be registered without conflicts. -// -// **Validates: Requirements 1.5** -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Multiple routers nested under different prefixes register all routes - /// - /// For any set of routers with routes nested under different prefixes, - /// all routes should be registered and the total count should equal the - /// sum of routes from all nested routers. - #[test] - fn prop_multiple_routers_all_routes_registered( - // Generate 2-3 different prefixes - prefix1_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - prefix2_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - // Generate route counts for each router (1-3 routes each) - num_routes1 in 1..4usize, - num_routes2 in 1..4usize, - ) { - // Build prefixes - let prefix1 = format!("/{}", prefix1_segments.join("/")); - let prefix2 = format!("/{}", prefix2_segments.join("/")); - - // Ensure prefixes are different - prop_assume!(prefix1 != prefix2); - - async fn handler() -> &'static str { "handler" } - - // Create first router with routes - let mut router1 = Router::new(); - for i in 0..num_routes1 { - let path = format!("/route1_{}", i); - router1 = router1.route(&path, get(handler)); - } - - // Create second router with routes - let mut router2 = Router::new(); - for i in 0..num_routes2 { - let path = format!("/route2_{}", i); - router2 = router2.route(&path, get(handler)); - } - - // Nest both routers - let app = Router::new() - .nest(&prefix1, router1) - .nest(&prefix2, router2); - - let routes = app.registered_routes(); - - // Property: Total route count should equal sum of all nested routes - let expected_count = num_routes1 + num_routes2; - prop_assert_eq!( - routes.len(), - expected_count, - "Should have {} routes ({}+{}), got {}", - expected_count, num_routes1, num_routes2, routes.len() - ); - - // Property: All routes from router1 should be registered with prefix1 - for i in 0..num_routes1 { - let expected_path = format!("{}/route1_{}", prefix1, i); - let matchit_path = convert_path_params(&expected_path); - prop_assert!( - routes.contains_key(&matchit_path), - "Route '{}' should be registered", - expected_path - ); - } - - // Property: All routes from router2 should be registered with prefix2 - for i in 0..num_routes2 { - let expected_path = format!("{}/route2_{}", prefix2, i); - let matchit_path = convert_path_params(&expected_path); - prop_assert!( - routes.contains_key(&matchit_path), - "Route '{}' should be registered", - expected_path - ); - } - } - - /// Property: Multiple routers with same internal routes don't interfere - /// - /// For any set of routers with identical internal route structures nested - /// under different prefixes, all routes should be independently matchable. - #[test] - fn prop_multiple_routers_no_interference( - prefix1 in "[a-z][a-z0-9]{1,5}", - prefix2 in "[a-z][a-z0-9]{1,5}", - route_segment in "[a-z][a-z0-9]{1,5}", - param_value1 in "[a-z0-9]{1,10}", - param_value2 in "[a-z0-9]{1,10}", - ) { - // Ensure prefixes are different - prop_assume!(prefix1 != prefix2); - - let prefix1 = format!("/{}", prefix1); - let prefix2 = format!("/{}", prefix2); - - async fn handler() -> &'static str { "handler" } - - // Create two routers with identical internal structure - let router1 = Router::new() - .route(&format!("/{}", route_segment), get(handler)) - .route("/{id}", get(handler)); - - let router2 = Router::new() - .route(&format!("/{}", route_segment), get(handler)) - .route("/{id}", get(handler)); - - // Nest both routers - let app = Router::new() - .nest(&prefix1, router1) - .nest(&prefix2, router2); - - // Property: Routes under prefix1 should be matchable - let path1_static = format!("{}/{}", prefix1, route_segment); - match app.match_route(&path1_static, &Method::GET) { - RouteMatch::Found { params, .. } => { - prop_assert!(params.is_empty(), "Static path should have no params"); - } - _ => { - prop_assert!(false, "Route '{}' should be found", path1_static); - } - } - - let path1_param = format!("{}/{}", prefix1, param_value1); - match app.match_route(&path1_param, &Method::GET) { - RouteMatch::Found { params, .. } => { - prop_assert_eq!( - params.get("id"), - Some(¶m_value1.to_string()), - "Parameter should be extracted correctly" - ); - } - _ => { - prop_assert!(false, "Route '{}' should be found", path1_param); - } - } - - // Property: Routes under prefix2 should be matchable independently - let path2_static = format!("{}/{}", prefix2, route_segment); - match app.match_route(&path2_static, &Method::GET) { - RouteMatch::Found { params, .. } => { - prop_assert!(params.is_empty(), "Static path should have no params"); - } - _ => { - prop_assert!(false, "Route '{}' should be found", path2_static); - } - } - - let path2_param = format!("{}/{}", prefix2, param_value2); - match app.match_route(&path2_param, &Method::GET) { - RouteMatch::Found { params, .. } => { - prop_assert_eq!( - params.get("id"), - Some(¶m_value2.to_string()), - "Parameter should be extracted correctly" - ); - } - _ => { - prop_assert!(false, "Route '{}' should be found", path2_param); - } - } - } - - /// Property: Multiple routers preserve HTTP methods independently - /// - /// For any set of routers with different HTTP methods nested under different - /// prefixes, each route should preserve its own set of allowed methods. - #[test] - fn prop_multiple_routers_preserve_methods( - prefix1 in "[a-z][a-z0-9]{1,5}", - prefix2 in "[a-z][a-z0-9]{1,5}", - route_segment in "[a-z][a-z0-9]{1,5}", - router1_use_get in any::(), - router1_use_post in any::(), - router2_use_get in any::(), - router2_use_put in any::(), - ) { - // Ensure at least one method per router - prop_assume!(router1_use_get || router1_use_post); - prop_assume!(router2_use_get || router2_use_put); - // Ensure prefixes are different - prop_assume!(prefix1 != prefix2); - - let prefix1 = format!("/{}", prefix1); - let prefix2 = format!("/{}", prefix2); - let route_path = format!("/{}", route_segment); - - async fn handler() -> &'static str { "handler" } - - // Build router1 with selected methods - let mut method_router1 = MethodRouter::new(); - let mut expected_methods1: Vec = Vec::new(); - if router1_use_get { - let get_router = get(handler); - for (method, h) in get_router.handlers { - method_router1.handlers.insert(method.clone(), h); - expected_methods1.push(method); - } - } - if router1_use_post { - let post_router = post(handler); - for (method, h) in post_router.handlers { - method_router1.handlers.insert(method.clone(), h); - expected_methods1.push(method); - } - } - - // Build router2 with selected methods - let mut method_router2 = MethodRouter::new(); - let mut expected_methods2: Vec = Vec::new(); - if router2_use_get { - let get_router = get(handler); - for (method, h) in get_router.handlers { - method_router2.handlers.insert(method.clone(), h); - expected_methods2.push(method); - } - } - if router2_use_put { - let put_router = put(handler); - for (method, h) in put_router.handlers { - method_router2.handlers.insert(method.clone(), h); - expected_methods2.push(method); - } - } - - let router1 = Router::new().route(&route_path, method_router1); - let router2 = Router::new().route(&route_path, method_router2); - - let app = Router::new() - .nest(&prefix1, router1) - .nest(&prefix2, router2); - - let full_path1 = format!("{}{}", prefix1, route_path); - let full_path2 = format!("{}{}", prefix2, route_path); - - // Property: Router1's methods should be preserved - for method in &expected_methods1 { - match app.match_route(&full_path1, method) { - RouteMatch::Found { .. } => {} - _ => { - prop_assert!(false, "Method {:?} should be found for {}", method, full_path1); - } - } - } - - // Property: Router2's methods should be preserved - for method in &expected_methods2 { - match app.match_route(&full_path2, method) { - RouteMatch::Found { .. } => {} - _ => { - prop_assert!(false, "Method {:?} should be found for {}", method, full_path2); - } - } - } - - // Property: Methods not registered should return MethodNotAllowed - if !expected_methods1.contains(&Method::DELETE) { - match app.match_route(&full_path1, &Method::DELETE) { - RouteMatch::MethodNotAllowed { allowed } => { - for method in &expected_methods1 { - prop_assert!( - allowed.contains(method), - "Allowed methods for {} should contain {:?}", - full_path1, method - ); - } - } - _ => { - prop_assert!(false, "DELETE should return MethodNotAllowed for {}", full_path1); - } - } - } - } - - /// Property: Three or more routers can be composed without conflicts - /// - /// For any set of three routers nested under different prefixes, - /// all routes should be registered without conflicts. - #[test] - fn prop_three_routers_composition( - prefix1 in "[a-z]{1,3}", - prefix2 in "[a-z]{4,6}", - prefix3 in "[a-z]{7,9}", - num_routes in 1..3usize, - ) { - let prefix1 = format!("/{}", prefix1); - let prefix2 = format!("/{}", prefix2); - let prefix3 = format!("/{}", prefix3); - - async fn handler() -> &'static str { "handler" } - - // Create three routers with same number of routes - let mut router1 = Router::new(); - let mut router2 = Router::new(); - let mut router3 = Router::new(); - - for i in 0..num_routes { - let path = format!("/item{}", i); - router1 = router1.route(&path, get(handler)); - router2 = router2.route(&path, get(handler)); - router3 = router3.route(&path, get(handler)); - } - - // Nest all three routers - let app = Router::new() - .nest(&prefix1, router1) - .nest(&prefix2, router2) - .nest(&prefix3, router3); - - let routes = app.registered_routes(); - - // Property: Total route count should be 3 * num_routes - let expected_count = 3 * num_routes; - prop_assert_eq!( - routes.len(), - expected_count, - "Should have {} routes, got {}", - expected_count, routes.len() - ); - - // Property: All routes should be matchable - for i in 0..num_routes { - let path1 = format!("{}/item{}", prefix1, i); - let path2 = format!("{}/item{}", prefix2, i); - let path3 = format!("{}/item{}", prefix3, i); - - match app.match_route(&path1, &Method::GET) { - RouteMatch::Found { .. } => {} - _ => prop_assert!(false, "Route '{}' should be found", path1), - } - match app.match_route(&path2, &Method::GET) { - RouteMatch::Found { .. } => {} - _ => prop_assert!(false, "Route '{}' should be found", path2), - } - match app.match_route(&path3, &Method::GET) { - RouteMatch::Found { .. } => {} - _ => prop_assert!(false, "Route '{}' should be found", path3), - } - } - } -} -proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Nested routes with same path structure but different param names conflict - /// - /// For any existing route with a parameter and a nested route that would create - /// the same path structure with a different parameter name, the router should - /// detect and report the conflict. - #[test] - fn prop_nested_route_conflict_different_param_names( - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 0..2), - param1 in "[a-z][a-z0-9]{1,5}", - param2 in "[a-z][a-z0-9]{1,5}", - ) { - // Ensure param names are different - prop_assume!(param1 != param2); - - async fn handler1() -> &'static str { "handler1" } - async fn handler2() -> &'static str { "handler2" } - - let prefix = format!("/{}", prefix_segments.join("/")); - - // Build the existing route path (with param1) - let existing_path = if route_segments.is_empty() { - format!("{}/{{{}}}", prefix, param1) - } else { - format!("{}/{}/{{{}}}", prefix, route_segments.join("/"), param1) - }; - - // Build the nested route path (with param2) - let nested_path = if route_segments.is_empty() { - format!("/{{{}}}", param2) - } else { - format!("/{}/{{{}}}", route_segments.join("/"), param2) - }; - - // Try to create a conflict - let result = catch_unwind(AssertUnwindSafe(|| { - let parent = Router::new().route(&existing_path, get(handler1)); - let nested = Router::new().route(&nested_path, get(handler2)); - let _app = parent.nest(&prefix, nested); - })); - - // Property: Should detect conflict - prop_assert!( - result.is_err(), - "Nested route '{}{}' should conflict with existing route '{}' but didn't", - prefix, nested_path, existing_path - ); - - // Property: Error message should contain conflict information - if let Err(panic_info) = result { - if let Some(msg) = panic_info.downcast_ref::() { - prop_assert!( - msg.contains("ROUTE CONFLICT DETECTED"), - "Error should contain 'ROUTE CONFLICT DETECTED', got: {}", - msg - ); - prop_assert!( - msg.contains("Existing:") && msg.contains("New:"), - "Error should contain both 'Existing:' and 'New:' labels, got: {}", - msg - ); - } - } - } - - /// Property: Nested routes with exact same path conflict - /// - /// For any existing route and a nested route that would create the exact - /// same path, the router should detect and report the conflict. - #[test] - fn prop_nested_route_conflict_exact_same_path( - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - ) { - async fn handler1() -> &'static str { "handler1" } - async fn handler2() -> &'static str { "handler2" } - - let prefix = format!("/{}", prefix_segments.join("/")); - let route_path = format!("/{}", route_segments.join("/")); - - // Build the full existing path - let existing_path = format!("{}{}", prefix, route_path); - - // Try to create a conflict by nesting a route that creates the same path - let result = catch_unwind(AssertUnwindSafe(|| { - let parent = Router::new().route(&existing_path, get(handler1)); - let nested = Router::new().route(&route_path, get(handler2)); - let _app = parent.nest(&prefix, nested); - })); - - // Property: Should detect conflict - prop_assert!( - result.is_err(), - "Nested route '{}{}' should conflict with existing route '{}' but didn't", - prefix, route_path, existing_path - ); - } - - /// Property: Nested routes under different prefixes don't conflict - /// - /// For any two nested routers with the same internal routes but different - /// prefixes, they should not conflict. - #[test] - fn prop_nested_routes_different_prefixes_no_conflict( - prefix1_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - prefix2_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), - has_param in any::(), - ) { - // Build prefixes - let prefix1 = format!("/{}", prefix1_segments.join("/")); - let prefix2 = format!("/{}", prefix2_segments.join("/")); - - // Ensure prefixes are different - prop_assume!(prefix1 != prefix2); - - async fn handler1() -> &'static str { "handler1" } - async fn handler2() -> &'static str { "handler2" } - - // Build the route path - let route_path = if has_param { - format!("/{}/{{id}}", route_segments.join("/")) - } else { - format!("/{}", route_segments.join("/")) - }; - - // Try to nest both routers - should NOT conflict - let result = catch_unwind(AssertUnwindSafe(|| { - let nested1 = Router::new().route(&route_path, get(handler1)); - let nested2 = Router::new().route(&route_path, get(handler2)); - - let app = Router::new() - .nest(&prefix1, nested1) - .nest(&prefix2, nested2); - - app.registered_routes().len() - })); - - // Property: Should NOT conflict - prop_assert!( - result.is_ok(), - "Routes under different prefixes '{}' and '{}' should not conflict", - prefix1, prefix2 - ); - - if let Ok(count) = result { - prop_assert_eq!(count, 2, "Should have registered 2 routes"); - } - } - - /// Property: Conflict error message contains resolution guidance - /// - /// When a nested route conflict is detected, the error message should - /// include guidance on how to resolve the conflict. - #[test] - fn prop_nested_conflict_error_contains_guidance( - prefix in "[a-z][a-z0-9]{1,5}", - segment in "[a-z][a-z0-9]{1,5}", - param1 in "[a-z][a-z0-9]{1,5}", - param2 in "[a-z][a-z0-9]{1,5}", - ) { - prop_assume!(param1 != param2); - - async fn handler1() -> &'static str { "handler1" } - async fn handler2() -> &'static str { "handler2" } - - let prefix = format!("/{}", prefix); - let existing_path = format!("{}/{}/{{{}}}", prefix, segment, param1); - let nested_path = format!("/{}/{{{}}}", segment, param2); - - let result = catch_unwind(AssertUnwindSafe(|| { - let parent = Router::new().route(&existing_path, get(handler1)); - let nested = Router::new().route(&nested_path, get(handler2)); - let _app = parent.nest(&prefix, nested); - })); - - prop_assert!(result.is_err(), "Should have detected conflict"); - - if let Err(panic_info) = result { - if let Some(msg) = panic_info.downcast_ref::() { - prop_assert!( - msg.contains("How to resolve:"), - "Error should contain 'How to resolve:' guidance, got: {}", - msg - ); - prop_assert!( - msg.contains("Use different path patterns") || - msg.contains("different path patterns"), - "Error should suggest using different path patterns, got: {}", - msg - ); - } - } - } -} diff --git a/crates/rustapi-core/src/router/tests/unit.rs b/crates/rustapi-core/src/router/tests/unit.rs deleted file mode 100644 index 265a853..0000000 --- a/crates/rustapi-core/src/router/tests/unit.rs +++ /dev/null @@ -1,756 +0,0 @@ -use crate::router::{ - convert_path_params, get, normalize_path_for_comparison, normalize_prefix, post, put, - MethodRouter, RouteMatch, Router, -}; -use http::Method; - -#[test] -fn test_convert_path_params() { - assert_eq!(convert_path_params("/users/{id}"), "/users/:id"); - assert_eq!( - convert_path_params("/users/{user_id}/posts/{post_id}"), - "/users/:user_id/posts/:post_id" - ); - assert_eq!(convert_path_params("/static/path"), "/static/path"); -} - -#[test] -fn test_normalize_path_for_comparison() { - assert_eq!(normalize_path_for_comparison("/users/:id"), "/users/:_"); - assert_eq!( - normalize_path_for_comparison("/users/:user_id"), - "/users/:_" - ); - assert_eq!( - normalize_path_for_comparison("/users/:id/posts/:post_id"), - "/users/:_/posts/:_" - ); - assert_eq!( - normalize_path_for_comparison("/static/path"), - "/static/path" - ); -} - -#[test] -fn test_normalize_prefix() { - // Basic cases - assert_eq!(normalize_prefix("api"), "/api"); - assert_eq!(normalize_prefix("/api"), "/api"); - assert_eq!(normalize_prefix("/api/"), "/api"); - assert_eq!(normalize_prefix("api/"), "/api"); - - // Multiple segments - assert_eq!(normalize_prefix("api/v1"), "/api/v1"); - assert_eq!(normalize_prefix("/api/v1"), "/api/v1"); - assert_eq!(normalize_prefix("/api/v1/"), "/api/v1"); - - // Edge cases: empty and root - assert_eq!(normalize_prefix(""), "/"); - assert_eq!(normalize_prefix("/"), "/"); - - // Multiple slashes - assert_eq!(normalize_prefix("//api"), "/api"); - assert_eq!(normalize_prefix("api//v1"), "/api/v1"); - assert_eq!(normalize_prefix("//api//v1//"), "/api/v1"); - assert_eq!(normalize_prefix("///"), "/"); -} - -#[test] -#[should_panic(expected = "ROUTE CONFLICT DETECTED")] -fn test_route_conflict_detection() { - async fn handler1() -> &'static str { - "handler1" - } - async fn handler2() -> &'static str { - "handler2" - } - - let _router = Router::new() - .route("/users/{id}", get(handler1)) - .route("/users/{user_id}", get(handler2)); // This should panic -} - -#[test] -fn test_no_conflict_different_paths() { - async fn handler1() -> &'static str { - "handler1" - } - async fn handler2() -> &'static str { - "handler2" - } - - let router = Router::new() - .route("/users/{id}", get(handler1)) - .route("/users/{id}/profile", get(handler2)); - - assert_eq!(router.registered_routes().len(), 2); -} - -#[test] -fn test_route_info_tracking() { - async fn handler() -> &'static str { - "handler" - } - - let router = Router::new().route("/users/{id}", get(handler)); - - let routes = router.registered_routes(); - assert_eq!(routes.len(), 1); - - let info = routes.get("/users/:id").unwrap(); - assert_eq!(info.path, "/users/{id}"); - assert_eq!(info.methods.len(), 1); - assert_eq!(info.methods[0], Method::GET); -} - -#[test] -fn test_basic_router_nesting() { - async fn list_users() -> &'static str { - "list users" - } - async fn get_user() -> &'static str { - "get user" - } - - let users_router = Router::new() - .route("/", get(list_users)) - .route("/{id}", get(get_user)); - - let app = Router::new().nest("/api/users", users_router); - - let routes = app.registered_routes(); - assert_eq!(routes.len(), 2); - - // Check that routes are registered with prefix - assert!(routes.contains_key("/api/users")); - assert!(routes.contains_key("/api/users/:id")); - - // Check display paths - let list_info = routes.get("/api/users").unwrap(); - assert_eq!(list_info.path, "/api/users"); - - let get_info = routes.get("/api/users/:id").unwrap(); - assert_eq!(get_info.path, "/api/users/{id}"); -} - -#[test] -fn test_nested_route_matching() { - async fn handler() -> &'static str { - "handler" - } - - let users_router = Router::new().route("/{id}", get(handler)); - - let app = Router::new().nest("/api/users", users_router); - - // Test that the route can be matched - match app.match_route("/api/users/123", &Method::GET) { - RouteMatch::Found { params, .. } => { - assert_eq!(params.get("id"), Some(&"123".to_string())); - } - _ => panic!("Route should be found"), - } -} - -#[test] -fn test_nested_route_matching_multiple_params() { - async fn handler() -> &'static str { - "handler" - } - - let posts_router = Router::new().route("/{user_id}/posts/{post_id}", get(handler)); - - let app = Router::new().nest("/api", posts_router); - - // Test that multiple parameters are correctly extracted - match app.match_route("/api/42/posts/100", &Method::GET) { - RouteMatch::Found { params, .. } => { - assert_eq!(params.get("user_id"), Some(&"42".to_string())); - assert_eq!(params.get("post_id"), Some(&"100".to_string())); - } - _ => panic!("Route should be found"), - } -} - -#[test] -fn test_nested_route_matching_static_path() { - async fn handler() -> &'static str { - "handler" - } - - let health_router = Router::new().route("/health", get(handler)); - - let app = Router::new().nest("/api/v1", health_router); - - // Test that static paths are correctly matched - match app.match_route("/api/v1/health", &Method::GET) { - RouteMatch::Found { params, .. } => { - assert!(params.is_empty(), "Static path should have no params"); - } - _ => panic!("Route should be found"), - } -} - -#[test] -fn test_nested_route_not_found() { - async fn handler() -> &'static str { - "handler" - } - - let users_router = Router::new().route("/users", get(handler)); - - let app = Router::new().nest("/api", users_router); - - // Test that non-existent paths return NotFound - match app.match_route("/api/posts", &Method::GET) { - RouteMatch::NotFound => { - // Expected - } - _ => panic!("Route should not be found"), - } - - // Test that wrong prefix returns NotFound - match app.match_route("/v2/users", &Method::GET) { - RouteMatch::NotFound => { - // Expected - } - _ => panic!("Route with wrong prefix should not be found"), - } -} - -#[test] -fn test_nested_route_method_not_allowed() { - async fn handler() -> &'static str { - "handler" - } - - let users_router = Router::new().route("/users", get(handler)); - - let app = Router::new().nest("/api", users_router); - - // Test that wrong method returns MethodNotAllowed - match app.match_route("/api/users", &Method::POST) { - RouteMatch::MethodNotAllowed { allowed } => { - assert!(allowed.contains(&Method::GET)); - assert!(!allowed.contains(&Method::POST)); - } - _ => panic!("Should return MethodNotAllowed"), - } -} - -#[test] -fn test_nested_route_multiple_methods() { - async fn get_handler() -> &'static str { - "get" - } - async fn post_handler() -> &'static str { - "post" - } - - // Create a method router with both GET and POST - let get_router = get(get_handler); - let post_router = post(post_handler); - let mut combined = MethodRouter::new(); - for (method, handler) in get_router.handlers { - combined.handlers.insert(method, handler); - } - for (method, handler) in post_router.handlers { - combined.handlers.insert(method, handler); - } - - let users_router = Router::new().route("/users", combined); - let app = Router::new().nest("/api", users_router); - - // Both GET and POST should work - match app.match_route("/api/users", &Method::GET) { - RouteMatch::Found { .. } => {} - _ => panic!("GET should be found"), - } - - match app.match_route("/api/users", &Method::POST) { - RouteMatch::Found { .. } => {} - _ => panic!("POST should be found"), - } - - // DELETE should return MethodNotAllowed with GET and POST in allowed - match app.match_route("/api/users", &Method::DELETE) { - RouteMatch::MethodNotAllowed { allowed } => { - assert!(allowed.contains(&Method::GET)); - assert!(allowed.contains(&Method::POST)); - } - _ => panic!("DELETE should return MethodNotAllowed"), - } -} - -#[test] -fn test_nested_router_prefix_normalization() { - async fn handler() -> &'static str { - "handler" - } - - // Test various prefix formats - let router1 = Router::new().route("/test", get(handler)); - let app1 = Router::new().nest("api", router1); - assert!(app1.registered_routes().contains_key("/api/test")); - - let router2 = Router::new().route("/test", get(handler)); - let app2 = Router::new().nest("/api/", router2); - assert!(app2.registered_routes().contains_key("/api/test")); - - let router3 = Router::new().route("/test", get(handler)); - let app3 = Router::new().nest("//api//", router3); - assert!(app3.registered_routes().contains_key("/api/test")); -} - -#[test] -fn test_state_tracking() { - #[derive(Clone)] - struct MyState(#[allow(dead_code)] String); - - let router = Router::new().state(MyState("test".to_string())); - - assert!(router.has_state::()); - assert!(!router.has_state::()); -} - -#[test] -fn test_state_merge_nested_only() { - #[derive(Clone, PartialEq, Debug)] - struct NestedState(String); - - async fn handler() -> &'static str { - "handler" - } - - // Create a router with state to use as source for merging - let state_source = Router::new().state(NestedState("nested".to_string())); - - let nested = Router::new().route("/test", get(handler)); - - let parent = Router::new() - .nest("/api", nested) - .merge_state::(&state_source); - - // Parent should now have the nested state - assert!(parent.has_state::()); - - // Verify the state value - let state = parent.state.get::().unwrap(); - assert_eq!(state.0, "nested"); -} - -#[test] -fn test_state_merge_parent_wins() { - #[derive(Clone, PartialEq, Debug)] - struct SharedState(String); - - async fn handler() -> &'static str { - "handler" - } - - // Create a router with state to use as source for merging - let state_source = Router::new().state(SharedState("nested".to_string())); - - let nested = Router::new().route("/test", get(handler)); - - let parent = Router::new() - .state(SharedState("parent".to_string())) - .nest("/api", nested) - .merge_state::(&state_source); - - // Parent should still have its own state (parent wins) - assert!(parent.has_state::()); - - // Verify the state value is from parent - let state = parent.state.get::().unwrap(); - assert_eq!(state.0, "parent"); -} - -#[test] -fn test_state_type_ids_merged_on_nest() { - #[derive(Clone)] - struct NestedState(#[allow(dead_code)] String); - - async fn handler() -> &'static str { - "handler" - } - - let nested = Router::new() - .route("/test", get(handler)) - .state(NestedState("nested".to_string())); - - let parent = Router::new().nest("/api", nested); - - // Parent should track the nested state type ID - assert!(parent - .state_type_ids() - .contains(&std::any::TypeId::of::())); -} - -#[test] -#[should_panic(expected = "ROUTE CONFLICT DETECTED")] -fn test_nested_route_conflict_with_existing_route() { - async fn handler1() -> &'static str { - "handler1" - } - async fn handler2() -> &'static str { - "handler2" - } - - // Create a parent router with an existing route - let parent = Router::new().route("/api/users/{id}", get(handler1)); - - // Create a nested router with a conflicting route - let nested = Router::new().route("/{user_id}", get(handler2)); - - // This should panic because /api/users/{id} conflicts with /api/users/{user_id} - let _app = parent.nest("/api/users", nested); -} - -#[test] -#[should_panic(expected = "ROUTE CONFLICT DETECTED")] -fn test_nested_route_conflict_same_path_different_param_names() { - async fn handler1() -> &'static str { - "handler1" - } - async fn handler2() -> &'static str { - "handler2" - } - - // Create two nested routers with same path structure but different param names - let nested1 = Router::new().route("/{id}", get(handler1)); - let nested2 = Router::new().route("/{user_id}", get(handler2)); - - // Nest both under the same prefix - should conflict - let _app = Router::new() - .nest("/api/users", nested1) - .nest("/api/users", nested2); -} - -#[test] -fn test_nested_route_conflict_error_contains_both_paths() { - use std::panic::{catch_unwind, AssertUnwindSafe}; - - async fn handler1() -> &'static str { - "handler1" - } - async fn handler2() -> &'static str { - "handler2" - } - - let result = catch_unwind(AssertUnwindSafe(|| { - let parent = Router::new().route("/api/users/{id}", get(handler1)); - let nested = Router::new().route("/{user_id}", get(handler2)); - let _app = parent.nest("/api/users", nested); - })); - - assert!(result.is_err(), "Should have panicked due to conflict"); - - if let Err(panic_info) = result { - if let Some(msg) = panic_info.downcast_ref::() { - assert!( - msg.contains("ROUTE CONFLICT DETECTED"), - "Error should contain 'ROUTE CONFLICT DETECTED'" - ); - assert!( - msg.contains("Existing:") && msg.contains("New:"), - "Error should contain both 'Existing:' and 'New:' labels" - ); - assert!( - msg.contains("How to resolve:"), - "Error should contain resolution guidance" - ); - } - } -} - -#[test] -fn test_nested_routes_no_conflict_different_prefixes() { - async fn handler1() -> &'static str { - "handler1" - } - async fn handler2() -> &'static str { - "handler2" - } - - // Create two nested routers with same internal paths but different prefixes - let nested1 = Router::new().route("/{id}", get(handler1)); - let nested2 = Router::new().route("/{id}", get(handler2)); - - // Nest under different prefixes - should NOT conflict - let app = Router::new() - .nest("/api/users", nested1) - .nest("/api/posts", nested2); - - assert_eq!(app.registered_routes().len(), 2); - assert!(app.registered_routes().contains_key("/api/users/:id")); - assert!(app.registered_routes().contains_key("/api/posts/:id")); -} - -// **Feature: router-nesting, Property 4: Multiple Router Composition** -// Tests for nesting multiple routers under different prefixes -// **Validates: Requirements 1.5** - -#[test] -fn test_multiple_router_composition_all_routes_registered() { - async fn users_list() -> &'static str { - "users list" - } - async fn users_get() -> &'static str { - "users get" - } - async fn posts_list() -> &'static str { - "posts list" - } - async fn posts_get() -> &'static str { - "posts get" - } - async fn comments_list() -> &'static str { - "comments list" - } - - // Create multiple sub-routers with different routes - let users_router = Router::new() - .route("/", get(users_list)) - .route("/{id}", get(users_get)); - - let posts_router = Router::new() - .route("/", get(posts_list)) - .route("/{id}", get(posts_get)); - - let comments_router = Router::new().route("/", get(comments_list)); - - // Nest all routers under different prefixes - let app = Router::new() - .nest("/api/users", users_router) - .nest("/api/posts", posts_router) - .nest("/api/comments", comments_router); - - // Verify all routes are registered (2 + 2 + 1 = 5 routes) - let routes = app.registered_routes(); - assert_eq!(routes.len(), 5, "Should have 5 routes registered"); - - // Verify users routes - assert!( - routes.contains_key("/api/users"), - "Should have /api/users route" - ); - assert!( - routes.contains_key("/api/users/:id"), - "Should have /api/users/:id route" - ); - - // Verify posts routes - assert!( - routes.contains_key("/api/posts"), - "Should have /api/posts route" - ); - assert!( - routes.contains_key("/api/posts/:id"), - "Should have /api/posts/:id route" - ); - - // Verify comments routes - assert!( - routes.contains_key("/api/comments"), - "Should have /api/comments route" - ); -} - -#[test] -fn test_multiple_router_composition_no_interference() { - async fn users_handler() -> &'static str { - "users" - } - async fn posts_handler() -> &'static str { - "posts" - } - async fn admin_handler() -> &'static str { - "admin" - } - - // Create routers with same internal structure but different prefixes - let users_router = Router::new() - .route("/list", get(users_handler)) - .route("/{id}", get(users_handler)); - - let posts_router = Router::new() - .route("/list", get(posts_handler)) - .route("/{id}", get(posts_handler)); - - let admin_router = Router::new() - .route("/list", get(admin_handler)) - .route("/{id}", get(admin_handler)); - - // Nest all routers - let app = Router::new() - .nest("/api/v1/users", users_router) - .nest("/api/v1/posts", posts_router) - .nest("/admin", admin_router); - - // Verify all routes are registered (2 + 2 + 2 = 6 routes) - let routes = app.registered_routes(); - assert_eq!(routes.len(), 6, "Should have 6 routes registered"); - - // Verify each prefix group has its routes - assert!(routes.contains_key("/api/v1/users/list")); - assert!(routes.contains_key("/api/v1/users/:id")); - assert!(routes.contains_key("/api/v1/posts/list")); - assert!(routes.contains_key("/api/v1/posts/:id")); - assert!(routes.contains_key("/admin/list")); - assert!(routes.contains_key("/admin/:id")); - - // Verify routes are matchable and don't interfere with each other - match app.match_route("/api/v1/users/list", &Method::GET) { - RouteMatch::Found { params, .. } => { - assert!(params.is_empty(), "Static path should have no params"); - } - _ => panic!("Should find /api/v1/users/list"), - } - - match app.match_route("/api/v1/posts/123", &Method::GET) { - RouteMatch::Found { params, .. } => { - assert_eq!(params.get("id"), Some(&"123".to_string())); - } - _ => panic!("Should find /api/v1/posts/123"), - } - - match app.match_route("/admin/456", &Method::GET) { - RouteMatch::Found { params, .. } => { - assert_eq!(params.get("id"), Some(&"456".to_string())); - } - _ => panic!("Should find /admin/456"), - } -} - -#[test] -fn test_multiple_router_composition_with_multiple_methods() { - async fn get_handler() -> &'static str { - "get" - } - async fn post_handler() -> &'static str { - "post" - } - async fn put_handler() -> &'static str { - "put" - } - - // Create routers with multiple HTTP methods - // Combine GET and POST for users root - let get_router = get(get_handler); - let post_router = post(post_handler); - let mut users_root_combined = MethodRouter::new(); - for (method, handler) in get_router.handlers { - users_root_combined.handlers.insert(method, handler); - } - for (method, handler) in post_router.handlers { - users_root_combined.handlers.insert(method, handler); - } - - // Combine GET and PUT for users/{id} - let get_router2 = get(get_handler); - let put_router = put(put_handler); - let mut users_id_combined = MethodRouter::new(); - for (method, handler) in get_router2.handlers { - users_id_combined.handlers.insert(method, handler); - } - for (method, handler) in put_router.handlers { - users_id_combined.handlers.insert(method, handler); - } - - let users_router = Router::new() - .route("/", users_root_combined) - .route("/{id}", users_id_combined); - - // Combine GET and POST for posts root - let get_router3 = get(get_handler); - let post_router2 = post(post_handler); - let mut posts_root_combined = MethodRouter::new(); - for (method, handler) in get_router3.handlers { - posts_root_combined.handlers.insert(method, handler); - } - for (method, handler) in post_router2.handlers { - posts_root_combined.handlers.insert(method, handler); - } - - let posts_router = Router::new().route("/", posts_root_combined); - - // Nest routers - let app = Router::new() - .nest("/users", users_router) - .nest("/posts", posts_router); - - // Verify routes are registered - let routes = app.registered_routes(); - assert_eq!(routes.len(), 3, "Should have 3 routes registered"); - - // Verify methods are preserved for users routes - let users_root = routes.get("/users").unwrap(); - assert!(users_root.methods.contains(&Method::GET)); - assert!(users_root.methods.contains(&Method::POST)); - - let users_id = routes.get("/users/:id").unwrap(); - assert!(users_id.methods.contains(&Method::GET)); - assert!(users_id.methods.contains(&Method::PUT)); - - // Verify methods are preserved for posts routes - let posts_root = routes.get("/posts").unwrap(); - assert!(posts_root.methods.contains(&Method::GET)); - assert!(posts_root.methods.contains(&Method::POST)); - - // Verify route matching works for all methods - match app.match_route("/users", &Method::GET) { - RouteMatch::Found { .. } => {} - _ => panic!("GET /users should be found"), - } - match app.match_route("/users", &Method::POST) { - RouteMatch::Found { .. } => {} - _ => panic!("POST /users should be found"), - } - match app.match_route("/users/123", &Method::PUT) { - RouteMatch::Found { .. } => {} - _ => panic!("PUT /users/123 should be found"), - } -} - -#[test] -fn test_multiple_router_composition_deep_nesting() { - async fn handler() -> &'static str { - "handler" - } - - // Create nested routers at different depth levels - let deep_router = Router::new().route("/action", get(handler)); - - let mid_router = Router::new().route("/info", get(handler)); - - let shallow_router = Router::new().route("/status", get(handler)); - - // Nest at different depths - let app = Router::new() - .nest("/api/v1/resources/items", deep_router) - .nest("/api/v1/resources", mid_router) - .nest("/api", shallow_router); - - // Verify all routes are registered - let routes = app.registered_routes(); - assert_eq!(routes.len(), 3, "Should have 3 routes registered"); - - assert!(routes.contains_key("/api/v1/resources/items/action")); - assert!(routes.contains_key("/api/v1/resources/info")); - assert!(routes.contains_key("/api/status")); - - // Verify all routes are matchable - match app.match_route("/api/v1/resources/items/action", &Method::GET) { - RouteMatch::Found { .. } => {} - _ => panic!("Should find deep route"), - } - match app.match_route("/api/v1/resources/info", &Method::GET) { - RouteMatch::Found { .. } => {} - _ => panic!("Should find mid route"), - } - match app.match_route("/api/status", &Method::GET) { - RouteMatch::Found { .. } => {} - _ => panic!("Should find shallow route"), - } -} diff --git a/crates/rustapi-core/tests/support/extract_lib.rs b/crates/rustapi-core/tests/support/extract_lib.rs new file mode 100644 index 0000000..9392e16 --- /dev/null +++ b/crates/rustapi-core/tests/support/extract_lib.rs @@ -0,0 +1,706 @@ +use super::*; +use crate::path_params::PathParams; +use bytes::Bytes; +use http::{Extensions, Method}; +use proptest::prelude::*; +use proptest::test_runner::TestCaseError; +use std::sync::Arc; + +/// Create a test request with the given method, path, and headers +fn create_test_request_with_headers( + method: Method, + path: &str, + headers: Vec<(&str, &str)>, +) -> Request { + let uri: http::Uri = path.parse().unwrap(); + let mut builder = http::Request::builder().method(method).uri(uri); + + for (name, value) in headers { + builder = builder.header(name, value); + } + + let req = builder.body(()).unwrap(); + let (parts, _) = req.into_parts(); + + Request::new( + parts, + crate::request::BodyVariant::Buffered(Bytes::new()), + Arc::new(Extensions::new()), + PathParams::new(), + ) +} + +/// Create a test request with extensions +fn create_test_request_with_extensions( + method: Method, + path: &str, + extension: T, +) -> Request { + let uri: http::Uri = path.parse().unwrap(); + let builder = http::Request::builder().method(method).uri(uri); + + let req = builder.body(()).unwrap(); + let (mut parts, _) = req.into_parts(); + parts.extensions.insert(extension); + + Request::new( + parts, + crate::request::BodyVariant::Buffered(Bytes::new()), + Arc::new(Extensions::new()), + PathParams::new(), + ) +} + +// **Feature: phase3-batteries-included, Property 14: Headers extractor completeness** +// +// For any request with headers H, the `Headers` extractor SHALL return a map +// containing all key-value pairs in H. +// +// **Validates: Requirements 5.1** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn prop_headers_extractor_completeness( + // Generate random header names and values + // Using alphanumeric strings to ensure valid header names/values + headers in prop::collection::vec( + ( + "[a-z][a-z0-9-]{0,20}", // Valid header name pattern + "[a-zA-Z0-9 ]{1,50}" // Valid header value pattern + ), + 0..10 + ) + ) { + let result: Result<(), TestCaseError> = (|| { + // Convert to header tuples + let header_tuples: Vec<(&str, &str)> = headers + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + // Create request with headers + let request = create_test_request_with_headers( + Method::GET, + "/test", + header_tuples.clone(), + ); + + // Extract headers + let extracted = Headers::from_request_parts(&request) + .map_err(|e| TestCaseError::fail(format!("Failed to extract headers: {}", e)))?; + + // Verify all original headers are present + // HTTP allows duplicate headers - get_all() returns all values for a header name + for (name, value) in &headers { + // Check that the header name exists + let all_values: Vec<_> = extracted.get_all(name.as_str()).iter().collect(); + prop_assert!( + !all_values.is_empty(), + "Header '{}' not found", + name + ); + + // Check that the value is among the extracted values + let value_found = all_values.iter().any(|v| { + v.to_str().map(|s| s == value.as_str()).unwrap_or(false) + }); + + prop_assert!( + value_found, + "Header '{}' value '{}' not found in extracted values", + name, + value + ); + } + + Ok(()) + })(); + result?; + } +} + +// **Feature: phase3-batteries-included, Property 15: HeaderValue extractor correctness** +// +// For any request with header "X" having value V, `HeaderValue::extract(req, "X")` SHALL return V; +// for requests without header "X", it SHALL return an error. +// +// **Validates: Requirements 5.2** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn prop_header_value_extractor_correctness( + header_name in "[a-z][a-z0-9-]{0,20}", + header_value in "[a-zA-Z0-9 ]{1,50}", + has_header in prop::bool::ANY, + ) { + let result: Result<(), TestCaseError> = (|| { + let headers = if has_header { + vec![(header_name.as_str(), header_value.as_str())] + } else { + vec![] + }; + + let _request = create_test_request_with_headers(Method::GET, "/test", headers); + + // We need to use a static string for the header name in the extractor + // So we'll test with a known header name + let test_header = "x-test-header"; + let request_with_known_header = if has_header { + create_test_request_with_headers( + Method::GET, + "/test", + vec![(test_header, header_value.as_str())], + ) + } else { + create_test_request_with_headers(Method::GET, "/test", vec![]) + }; + + let result = HeaderValue::extract(&request_with_known_header, test_header); + + if has_header { + let extracted = result + .map_err(|e| TestCaseError::fail(format!("Expected header to be found: {}", e)))?; + prop_assert_eq!( + extracted.value(), + header_value.as_str(), + "Header value mismatch" + ); + } else { + prop_assert!( + result.is_err(), + "Expected error when header is missing" + ); + } + + Ok(()) + })(); + result?; + } +} + +// **Feature: phase3-batteries-included, Property 17: ClientIp extractor with forwarding** +// +// For any request with socket IP S and X-Forwarded-For header F, when forwarding is enabled, +// `ClientIp` SHALL return the first IP in F; when disabled, it SHALL return S. +// +// **Validates: Requirements 5.4** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn prop_client_ip_extractor_with_forwarding( + // Generate valid IPv4 addresses + forwarded_ip in (0u8..=255, 0u8..=255, 0u8..=255, 0u8..=255) + .prop_map(|(a, b, c, d)| format!("{}.{}.{}.{}", a, b, c, d)), + socket_ip in (0u8..=255, 0u8..=255, 0u8..=255, 0u8..=255) + .prop_map(|(a, b, c, d)| std::net::IpAddr::V4(std::net::Ipv4Addr::new(a, b, c, d))), + has_forwarded_header in prop::bool::ANY, + trust_proxy in prop::bool::ANY, + ) { + let result: Result<(), TestCaseError> = (|| { + let headers = if has_forwarded_header { + vec![("x-forwarded-for", forwarded_ip.as_str())] + } else { + vec![] + }; + + // Create request with headers + let uri: http::Uri = "/test".parse().unwrap(); + let mut builder = http::Request::builder().method(Method::GET).uri(uri); + for (name, value) in &headers { + builder = builder.header(*name, *value); + } + let req = builder.body(()).unwrap(); + let (mut parts, _) = req.into_parts(); + + // Add socket address to extensions + let socket_addr = std::net::SocketAddr::new(socket_ip, 8080); + parts.extensions.insert(socket_addr); + + let request = Request::new( + parts, + crate::request::BodyVariant::Buffered(Bytes::new()), + Arc::new(Extensions::new()), + PathParams::new(), + ); + + let extracted = ClientIp::extract_with_config(&request, trust_proxy) + .map_err(|e| TestCaseError::fail(format!("Failed to extract ClientIp: {}", e)))?; + + if trust_proxy && has_forwarded_header { + // Should use X-Forwarded-For + let expected_ip: std::net::IpAddr = forwarded_ip.parse() + .map_err(|e| TestCaseError::fail(format!("Invalid IP: {}", e)))?; + prop_assert_eq!( + extracted.0, + expected_ip, + "Should use X-Forwarded-For IP when trust_proxy is enabled" + ); + } else { + // Should use socket IP + prop_assert_eq!( + extracted.0, + socket_ip, + "Should use socket IP when trust_proxy is disabled or no X-Forwarded-For" + ); + } + + Ok(()) + })(); + result?; + } +} + +// **Feature: phase3-batteries-included, Property 18: Extension extractor retrieval** +// +// For any type T and value V inserted into request extensions by middleware, +// `Extension` SHALL return V. +// +// **Validates: Requirements 5.5** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn prop_extension_extractor_retrieval( + value in any::(), + has_extension in prop::bool::ANY, + ) { + let result: Result<(), TestCaseError> = (|| { + // Create a simple wrapper type for testing + #[derive(Clone, Debug, PartialEq)] + struct TestExtension(i64); + + let uri: http::Uri = "/test".parse().unwrap(); + let builder = http::Request::builder().method(Method::GET).uri(uri); + let req = builder.body(()).unwrap(); + let (mut parts, _) = req.into_parts(); + + if has_extension { + parts.extensions.insert(TestExtension(value)); + } + + let request = Request::new( + parts, + crate::request::BodyVariant::Buffered(Bytes::new()), + Arc::new(Extensions::new()), + PathParams::new(), + ); + + let result = Extension::::from_request_parts(&request); + + if has_extension { + let extracted = result + .map_err(|e| TestCaseError::fail(format!("Expected extension to be found: {}", e)))?; + prop_assert_eq!( + extracted.0, + TestExtension(value), + "Extension value mismatch" + ); + } else { + prop_assert!( + result.is_err(), + "Expected error when extension is missing" + ); + } + + Ok(()) + })(); + result?; + } +} + +// Unit tests for basic functionality + +#[test] +fn test_headers_extractor_basic() { + let request = create_test_request_with_headers( + Method::GET, + "/test", + vec![ + ("content-type", "application/json"), + ("accept", "text/html"), + ], + ); + + let headers = Headers::from_request_parts(&request).unwrap(); + + assert!(headers.contains("content-type")); + assert!(headers.contains("accept")); + assert!(!headers.contains("x-custom")); + assert_eq!(headers.len(), 2); +} + +#[test] +fn test_header_value_extractor_present() { + let request = create_test_request_with_headers( + Method::GET, + "/test", + vec![("authorization", "Bearer token123")], + ); + + let result = HeaderValue::extract(&request, "authorization"); + assert!(result.is_ok()); + assert_eq!(result.unwrap().value(), "Bearer token123"); +} + +#[test] +fn test_header_value_extractor_missing() { + let request = create_test_request_with_headers(Method::GET, "/test", vec![]); + + let result = HeaderValue::extract(&request, "authorization"); + assert!(result.is_err()); +} + +#[test] +fn test_client_ip_from_forwarded_header() { + let request = create_test_request_with_headers( + Method::GET, + "/test", + vec![("x-forwarded-for", "192.168.1.100, 10.0.0.1")], + ); + + let ip = ClientIp::extract_with_config(&request, true).unwrap(); + assert_eq!(ip.0, "192.168.1.100".parse::().unwrap()); +} + +#[test] +fn test_client_ip_ignores_forwarded_when_not_trusted() { + let uri: http::Uri = "/test".parse().unwrap(); + let builder = http::Request::builder() + .method(Method::GET) + .uri(uri) + .header("x-forwarded-for", "192.168.1.100"); + let req = builder.body(()).unwrap(); + let (mut parts, _) = req.into_parts(); + + let socket_addr = std::net::SocketAddr::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, 1)), + 8080, + ); + parts.extensions.insert(socket_addr); + + let request = Request::new( + parts, + crate::request::BodyVariant::Buffered(Bytes::new()), + Arc::new(Extensions::new()), + PathParams::new(), + ); + + let ip = ClientIp::extract_with_config(&request, false).unwrap(); + assert_eq!(ip.0, "10.0.0.1".parse::().unwrap()); +} + +#[test] +fn test_extension_extractor_present() { + #[derive(Clone, Debug, PartialEq)] + struct MyData(String); + + let request = + create_test_request_with_extensions(Method::GET, "/test", MyData("hello".to_string())); + + let result = Extension::::from_request_parts(&request); + assert!(result.is_ok()); + assert_eq!(result.unwrap().0, MyData("hello".to_string())); +} + +#[test] +fn test_extension_extractor_missing() { + #[derive(Clone, Debug)] + #[allow(dead_code)] + struct MyData(String); + + let request = create_test_request_with_headers(Method::GET, "/test", vec![]); + + let result = Extension::::from_request_parts(&request); + assert!(result.is_err()); +} + +// Cookies tests (feature-gated) +#[cfg(feature = "cookies")] +mod cookies_tests { + use super::*; + + // **Feature: phase3-batteries-included, Property 16: Cookies extractor parsing** + // + // For any request with Cookie header containing cookies C, the `Cookies` extractor + // SHALL return a CookieJar containing exactly the cookies in C. + // Note: Duplicate cookie names result in only the last value being kept. + // + // **Validates: Requirements 5.3** + proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + #[test] + fn prop_cookies_extractor_parsing( + // Generate random cookie names and values + // Using alphanumeric strings to ensure valid cookie names/values + cookies in prop::collection::vec( + ( + "[a-zA-Z][a-zA-Z0-9_]{0,15}", // Valid cookie name pattern + "[a-zA-Z0-9]{1,30}" // Valid cookie value pattern (no special chars) + ), + 0..5 + ) + ) { + let result: Result<(), TestCaseError> = (|| { + // Build cookie header string + let cookie_header = cookies + .iter() + .map(|(name, value)| format!("{}={}", name, value)) + .collect::>() + .join("; "); + + let headers = if !cookies.is_empty() { + vec![("cookie", cookie_header.as_str())] + } else { + vec![] + }; + + let request = create_test_request_with_headers(Method::GET, "/test", headers); + + // Extract cookies + let extracted = Cookies::from_request_parts(&request) + .map_err(|e| TestCaseError::fail(format!("Failed to extract cookies: {}", e)))?; + + // Build expected cookies map - last value wins for duplicate names + let mut expected_cookies: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + for (name, value) in &cookies { + expected_cookies.insert(name.as_str(), value.as_str()); + } + + // Verify all expected cookies are present with correct values + for (name, expected_value) in &expected_cookies { + let cookie = extracted.get(name) + .ok_or_else(|| TestCaseError::fail(format!("Cookie '{}' not found", name)))?; + + prop_assert_eq!( + cookie.value(), + *expected_value, + "Cookie '{}' value mismatch", + name + ); + } + + // Count cookies in jar should match unique cookie names + let extracted_count = extracted.iter().count(); + prop_assert_eq!( + extracted_count, + expected_cookies.len(), + "Expected {} unique cookies, got {}", + expected_cookies.len(), + extracted_count + ); + + Ok(()) + })(); + result?; + } + } + + #[test] + fn test_cookies_extractor_basic() { + let request = create_test_request_with_headers( + Method::GET, + "/test", + vec![("cookie", "session=abc123; user=john")], + ); + + let cookies = Cookies::from_request_parts(&request).unwrap(); + + assert!(cookies.contains("session")); + assert!(cookies.contains("user")); + assert!(!cookies.contains("other")); + + assert_eq!(cookies.get("session").unwrap().value(), "abc123"); + assert_eq!(cookies.get("user").unwrap().value(), "john"); + } + + #[test] + fn test_cookies_extractor_empty() { + let request = create_test_request_with_headers(Method::GET, "/test", vec![]); + + let cookies = Cookies::from_request_parts(&request).unwrap(); + assert_eq!(cookies.iter().count(), 0); + } + + #[test] + fn test_cookies_extractor_single() { + let request = create_test_request_with_headers( + Method::GET, + "/test", + vec![("cookie", "token=xyz789")], + ); + + let cookies = Cookies::from_request_parts(&request).unwrap(); + assert_eq!(cookies.iter().count(), 1); + assert_eq!(cookies.get("token").unwrap().value(), "xyz789"); + } +} + +#[tokio::test] +async fn test_async_validated_json_with_state_context() { + use async_trait::async_trait; + use rustapi_validate::prelude::*; + use rustapi_validate::v2::{AsyncValidationRule, DatabaseValidator, ValidationContextBuilder}; + use serde::{Deserialize, Serialize}; + + struct MockDbValidator { + unique_values: Vec, + } + + #[async_trait] + impl DatabaseValidator for MockDbValidator { + async fn exists(&self, _table: &str, _column: &str, _value: &str) -> Result { + Ok(true) + } + async fn is_unique( + &self, + _table: &str, + _column: &str, + value: &str, + ) -> Result { + Ok(!self.unique_values.contains(&value.to_string())) + } + async fn is_unique_except( + &self, + _table: &str, + _column: &str, + value: &str, + _except_id: &str, + ) -> Result { + Ok(!self.unique_values.contains(&value.to_string())) + } + } + + #[derive(Debug, Deserialize, Serialize)] + struct TestUser { + email: String, + } + + impl Validate for TestUser { + fn validate_with_group( + &self, + _group: rustapi_validate::v2::ValidationGroup, + ) -> Result<(), rustapi_validate::v2::ValidationErrors> { + Ok(()) + } + } + + #[async_trait] + impl AsyncValidate for TestUser { + async fn validate_async_with_group( + &self, + ctx: &ValidationContext, + _group: rustapi_validate::v2::ValidationGroup, + ) -> Result<(), rustapi_validate::v2::ValidationErrors> { + let mut errors = rustapi_validate::v2::ValidationErrors::new(); + + let rule = AsyncUniqueRule::new("users", "email"); + if let Err(e) = rule.validate_async(&self.email, ctx).await { + errors.add("email", e); + } + + errors.into_result() + } + } + + // Test 1: Without context in state (should fail due to missing validator) + let uri: http::Uri = "/test".parse().unwrap(); + let user = TestUser { + email: "new@example.com".to_string(), + }; + let body_bytes = serde_json::to_vec(&user).unwrap(); + + let builder = http::Request::builder() + .method(Method::POST) + .uri(uri.clone()) + .header("content-type", "application/json"); + let req = builder.body(()).unwrap(); + let (parts, _) = req.into_parts(); + + // Construct Request with BodyVariant::Buffered + let mut request = Request::new( + parts, + crate::request::BodyVariant::Buffered(Bytes::from(body_bytes.clone())), + Arc::new(Extensions::new()), + PathParams::new(), + ); + + let result = AsyncValidatedJson::::from_request(&mut request).await; + + assert!(result.is_err(), "Expected error when validator is missing"); + let err = result.unwrap_err(); + let err_str = format!("{:?}", err); + assert!( + err_str.contains("Database validator not configured") || err_str.contains("async_unique"), + "Error should mention missing configuration or rule: {:?}", + err_str + ); + + // Test 2: With context in state (should succeed) + let db_validator = MockDbValidator { + unique_values: vec!["taken@example.com".to_string()], + }; + let ctx = ValidationContextBuilder::new() + .database(db_validator) + .build(); + + let mut extensions = Extensions::new(); + extensions.insert(ctx); + + let builder = http::Request::builder() + .method(Method::POST) + .uri(uri.clone()) + .header("content-type", "application/json"); + let req = builder.body(()).unwrap(); + let (parts, _) = req.into_parts(); + + let mut request = Request::new( + parts, + crate::request::BodyVariant::Buffered(Bytes::from(body_bytes.clone())), + Arc::new(extensions), + PathParams::new(), + ); + + let result = AsyncValidatedJson::::from_request(&mut request).await; + assert!( + result.is_ok(), + "Expected success when validator is present and value is unique. Error: {:?}", + result.err() + ); + + // Test 3: With context in state (should fail validation logic) + let user_taken = TestUser { + email: "taken@example.com".to_string(), + }; + let body_taken = serde_json::to_vec(&user_taken).unwrap(); + + let db_validator = MockDbValidator { + unique_values: vec!["taken@example.com".to_string()], + }; + let ctx = ValidationContextBuilder::new() + .database(db_validator) + .build(); + + let mut extensions = Extensions::new(); + extensions.insert(ctx); + + let builder = http::Request::builder() + .method(Method::POST) + .uri("/test") + .header("content-type", "application/json"); + let req = builder.body(()).unwrap(); + let (parts, _) = req.into_parts(); + + let mut request = Request::new( + parts, + crate::request::BodyVariant::Buffered(Bytes::from(body_taken)), + Arc::new(extensions), + PathParams::new(), + ); + + let result = AsyncValidatedJson::::from_request(&mut request).await; + assert!(result.is_err(), "Expected validation error for taken email"); +} diff --git a/crates/rustapi-core/tests/support/router_lib.rs b/crates/rustapi-core/tests/support/router_lib.rs new file mode 100644 index 0000000..205b8f2 --- /dev/null +++ b/crates/rustapi-core/tests/support/router_lib.rs @@ -0,0 +1,2592 @@ +use crate::router::{ + convert_path_params, delete, get, normalize_path_for_comparison, normalize_prefix, patch, post, + put, MethodRouter, RouteMatch, Router, +}; +use http::Method; +use proptest::prelude::*; +use std::panic::{catch_unwind, AssertUnwindSafe}; +#[test] +fn test_convert_path_params() { + assert_eq!(convert_path_params("/users/{id}"), "/users/:id"); + assert_eq!( + convert_path_params("/users/{user_id}/posts/{post_id}"), + "/users/:user_id/posts/:post_id" + ); + assert_eq!(convert_path_params("/static/path"), "/static/path"); +} + +#[test] +fn test_normalize_path_for_comparison() { + assert_eq!(normalize_path_for_comparison("/users/:id"), "/users/:_"); + assert_eq!( + normalize_path_for_comparison("/users/:user_id"), + "/users/:_" + ); + assert_eq!( + normalize_path_for_comparison("/users/:id/posts/:post_id"), + "/users/:_/posts/:_" + ); + assert_eq!( + normalize_path_for_comparison("/static/path"), + "/static/path" + ); +} + +#[test] +fn test_normalize_prefix() { + // Basic cases + assert_eq!(normalize_prefix("api"), "/api"); + assert_eq!(normalize_prefix("/api"), "/api"); + assert_eq!(normalize_prefix("/api/"), "/api"); + assert_eq!(normalize_prefix("api/"), "/api"); + + // Multiple segments + assert_eq!(normalize_prefix("api/v1"), "/api/v1"); + assert_eq!(normalize_prefix("/api/v1"), "/api/v1"); + assert_eq!(normalize_prefix("/api/v1/"), "/api/v1"); + + // Edge cases: empty and root + assert_eq!(normalize_prefix(""), "/"); + assert_eq!(normalize_prefix("/"), "/"); + + // Multiple slashes + assert_eq!(normalize_prefix("//api"), "/api"); + assert_eq!(normalize_prefix("api//v1"), "/api/v1"); + assert_eq!(normalize_prefix("//api//v1//"), "/api/v1"); + assert_eq!(normalize_prefix("///"), "/"); +} + +#[test] +#[should_panic(expected = "ROUTE CONFLICT DETECTED")] +fn test_route_conflict_detection() { + async fn handler1() -> &'static str { + "handler1" + } + async fn handler2() -> &'static str { + "handler2" + } + + let _router = Router::new() + .route("/users/{id}", get(handler1)) + .route("/users/{user_id}", get(handler2)); // This should panic +} + +#[test] +fn test_no_conflict_different_paths() { + async fn handler1() -> &'static str { + "handler1" + } + async fn handler2() -> &'static str { + "handler2" + } + + let router = Router::new() + .route("/users/{id}", get(handler1)) + .route("/users/{id}/profile", get(handler2)); + + assert_eq!(router.registered_routes().len(), 2); +} + +#[test] +fn test_route_info_tracking() { + async fn handler() -> &'static str { + "handler" + } + + let router = Router::new().route("/users/{id}", get(handler)); + + let routes = router.registered_routes(); + assert_eq!(routes.len(), 1); + + let info = routes.get("/users/:id").unwrap(); + assert_eq!(info.path, "/users/{id}"); + assert_eq!(info.methods.len(), 1); + assert_eq!(info.methods[0], Method::GET); +} + +#[test] +fn test_basic_router_nesting() { + async fn list_users() -> &'static str { + "list users" + } + async fn get_user() -> &'static str { + "get user" + } + + let users_router = Router::new() + .route("/", get(list_users)) + .route("/{id}", get(get_user)); + + let app = Router::new().nest("/api/users", users_router); + + let routes = app.registered_routes(); + assert_eq!(routes.len(), 2); + + // Check that routes are registered with prefix + assert!(routes.contains_key("/api/users")); + assert!(routes.contains_key("/api/users/:id")); + + // Check display paths + let list_info = routes.get("/api/users").unwrap(); + assert_eq!(list_info.path, "/api/users"); + + let get_info = routes.get("/api/users/:id").unwrap(); + assert_eq!(get_info.path, "/api/users/{id}"); +} + +#[test] +fn test_nested_route_matching() { + async fn handler() -> &'static str { + "handler" + } + + let users_router = Router::new().route("/{id}", get(handler)); + + let app = Router::new().nest("/api/users", users_router); + + // Test that the route can be matched + match app.match_route("/api/users/123", &Method::GET) { + RouteMatch::Found { params, .. } => { + assert_eq!(params.get("id"), Some(&"123".to_string())); + } + _ => panic!("Route should be found"), + } +} + +#[test] +fn test_nested_route_matching_multiple_params() { + async fn handler() -> &'static str { + "handler" + } + + let posts_router = Router::new().route("/{user_id}/posts/{post_id}", get(handler)); + + let app = Router::new().nest("/api", posts_router); + + // Test that multiple parameters are correctly extracted + match app.match_route("/api/42/posts/100", &Method::GET) { + RouteMatch::Found { params, .. } => { + assert_eq!(params.get("user_id"), Some(&"42".to_string())); + assert_eq!(params.get("post_id"), Some(&"100".to_string())); + } + _ => panic!("Route should be found"), + } +} + +#[test] +fn test_nested_route_matching_static_path() { + async fn handler() -> &'static str { + "handler" + } + + let health_router = Router::new().route("/health", get(handler)); + + let app = Router::new().nest("/api/v1", health_router); + + // Test that static paths are correctly matched + match app.match_route("/api/v1/health", &Method::GET) { + RouteMatch::Found { params, .. } => { + assert!(params.is_empty(), "Static path should have no params"); + } + _ => panic!("Route should be found"), + } +} + +#[test] +fn test_nested_route_not_found() { + async fn handler() -> &'static str { + "handler" + } + + let users_router = Router::new().route("/users", get(handler)); + + let app = Router::new().nest("/api", users_router); + + // Test that non-existent paths return NotFound + match app.match_route("/api/posts", &Method::GET) { + RouteMatch::NotFound => { + // Expected + } + _ => panic!("Route should not be found"), + } + + // Test that wrong prefix returns NotFound + match app.match_route("/v2/users", &Method::GET) { + RouteMatch::NotFound => { + // Expected + } + _ => panic!("Route with wrong prefix should not be found"), + } +} + +#[test] +fn test_nested_route_method_not_allowed() { + async fn handler() -> &'static str { + "handler" + } + + let users_router = Router::new().route("/users", get(handler)); + + let app = Router::new().nest("/api", users_router); + + // Test that wrong method returns MethodNotAllowed + match app.match_route("/api/users", &Method::POST) { + RouteMatch::MethodNotAllowed { allowed } => { + assert!(allowed.contains(&Method::GET)); + assert!(!allowed.contains(&Method::POST)); + } + _ => panic!("Should return MethodNotAllowed"), + } +} + +#[test] +fn test_nested_route_multiple_methods() { + async fn get_handler() -> &'static str { + "get" + } + async fn post_handler() -> &'static str { + "post" + } + + // Create a method router with both GET and POST + let get_router = get(get_handler); + let post_router = post(post_handler); + let mut combined = MethodRouter::new(); + for (method, handler) in get_router.handlers { + combined.handlers.insert(method, handler); + } + for (method, handler) in post_router.handlers { + combined.handlers.insert(method, handler); + } + + let users_router = Router::new().route("/users", combined); + let app = Router::new().nest("/api", users_router); + + // Both GET and POST should work + match app.match_route("/api/users", &Method::GET) { + RouteMatch::Found { .. } => {} + _ => panic!("GET should be found"), + } + + match app.match_route("/api/users", &Method::POST) { + RouteMatch::Found { .. } => {} + _ => panic!("POST should be found"), + } + + // DELETE should return MethodNotAllowed with GET and POST in allowed + match app.match_route("/api/users", &Method::DELETE) { + RouteMatch::MethodNotAllowed { allowed } => { + assert!(allowed.contains(&Method::GET)); + assert!(allowed.contains(&Method::POST)); + } + _ => panic!("DELETE should return MethodNotAllowed"), + } +} + +#[test] +fn test_nested_router_prefix_normalization() { + async fn handler() -> &'static str { + "handler" + } + + // Test various prefix formats + let router1 = Router::new().route("/test", get(handler)); + let app1 = Router::new().nest("api", router1); + assert!(app1.registered_routes().contains_key("/api/test")); + + let router2 = Router::new().route("/test", get(handler)); + let app2 = Router::new().nest("/api/", router2); + assert!(app2.registered_routes().contains_key("/api/test")); + + let router3 = Router::new().route("/test", get(handler)); + let app3 = Router::new().nest("//api//", router3); + assert!(app3.registered_routes().contains_key("/api/test")); +} + +#[test] +fn test_state_tracking() { + #[derive(Clone)] + struct MyState(#[allow(dead_code)] String); + + let router = Router::new().state(MyState("test".to_string())); + + assert!(router.has_state::()); + assert!(!router.has_state::()); +} + +#[test] +fn test_state_merge_nested_only() { + #[derive(Clone, PartialEq, Debug)] + struct NestedState(String); + + async fn handler() -> &'static str { + "handler" + } + + // Create a router with state to use as source for merging + let state_source = Router::new().state(NestedState("nested".to_string())); + + let nested = Router::new().route("/test", get(handler)); + + let parent = Router::new() + .nest("/api", nested) + .merge_state::(&state_source); + + // Parent should now have the nested state + assert!(parent.has_state::()); + + // Verify the state value + let state = parent.state.get::().unwrap(); + assert_eq!(state.0, "nested"); +} + +#[test] +fn test_state_merge_parent_wins() { + #[derive(Clone, PartialEq, Debug)] + struct SharedState(String); + + async fn handler() -> &'static str { + "handler" + } + + // Create a router with state to use as source for merging + let state_source = Router::new().state(SharedState("nested".to_string())); + + let nested = Router::new().route("/test", get(handler)); + + let parent = Router::new() + .state(SharedState("parent".to_string())) + .nest("/api", nested) + .merge_state::(&state_source); + + // Parent should still have its own state (parent wins) + assert!(parent.has_state::()); + + // Verify the state value is from parent + let state = parent.state.get::().unwrap(); + assert_eq!(state.0, "parent"); +} + +#[test] +fn test_state_type_ids_merged_on_nest() { + #[derive(Clone)] + struct NestedState(#[allow(dead_code)] String); + + async fn handler() -> &'static str { + "handler" + } + + let nested = Router::new() + .route("/test", get(handler)) + .state(NestedState("nested".to_string())); + + let parent = Router::new().nest("/api", nested); + + // Parent should track the nested state type ID + assert!(parent + .state_type_ids() + .contains(&std::any::TypeId::of::())); +} + +#[test] +#[should_panic(expected = "ROUTE CONFLICT DETECTED")] +fn test_nested_route_conflict_with_existing_route() { + async fn handler1() -> &'static str { + "handler1" + } + async fn handler2() -> &'static str { + "handler2" + } + + // Create a parent router with an existing route + let parent = Router::new().route("/api/users/{id}", get(handler1)); + + // Create a nested router with a conflicting route + let nested = Router::new().route("/{user_id}", get(handler2)); + + // This should panic because /api/users/{id} conflicts with /api/users/{user_id} + let _app = parent.nest("/api/users", nested); +} + +#[test] +#[should_panic(expected = "ROUTE CONFLICT DETECTED")] +fn test_nested_route_conflict_same_path_different_param_names() { + async fn handler1() -> &'static str { + "handler1" + } + async fn handler2() -> &'static str { + "handler2" + } + + // Create two nested routers with same path structure but different param names + let nested1 = Router::new().route("/{id}", get(handler1)); + let nested2 = Router::new().route("/{user_id}", get(handler2)); + + // Nest both under the same prefix - should conflict + let _app = Router::new() + .nest("/api/users", nested1) + .nest("/api/users", nested2); +} + +#[test] +fn test_nested_route_conflict_error_contains_both_paths() { + use std::panic::{catch_unwind, AssertUnwindSafe}; + + async fn handler1() -> &'static str { + "handler1" + } + async fn handler2() -> &'static str { + "handler2" + } + + let result = catch_unwind(AssertUnwindSafe(|| { + let parent = Router::new().route("/api/users/{id}", get(handler1)); + let nested = Router::new().route("/{user_id}", get(handler2)); + let _app = parent.nest("/api/users", nested); + })); + + assert!(result.is_err(), "Should have panicked due to conflict"); + + if let Err(panic_info) = result { + if let Some(msg) = panic_info.downcast_ref::() { + assert!( + msg.contains("ROUTE CONFLICT DETECTED"), + "Error should contain 'ROUTE CONFLICT DETECTED'" + ); + assert!( + msg.contains("Existing:") && msg.contains("New:"), + "Error should contain both 'Existing:' and 'New:' labels" + ); + assert!( + msg.contains("How to resolve:"), + "Error should contain resolution guidance" + ); + } + } +} + +#[test] +fn test_nested_routes_no_conflict_different_prefixes() { + async fn handler1() -> &'static str { + "handler1" + } + async fn handler2() -> &'static str { + "handler2" + } + + // Create two nested routers with same internal paths but different prefixes + let nested1 = Router::new().route("/{id}", get(handler1)); + let nested2 = Router::new().route("/{id}", get(handler2)); + + // Nest under different prefixes - should NOT conflict + let app = Router::new() + .nest("/api/users", nested1) + .nest("/api/posts", nested2); + + assert_eq!(app.registered_routes().len(), 2); + assert!(app.registered_routes().contains_key("/api/users/:id")); + assert!(app.registered_routes().contains_key("/api/posts/:id")); +} + +// **Feature: router-nesting, Property 4: Multiple Router Composition** +// Tests for nesting multiple routers under different prefixes +// **Validates: Requirements 1.5** + +#[test] +fn test_multiple_router_composition_all_routes_registered() { + async fn users_list() -> &'static str { + "users list" + } + async fn users_get() -> &'static str { + "users get" + } + async fn posts_list() -> &'static str { + "posts list" + } + async fn posts_get() -> &'static str { + "posts get" + } + async fn comments_list() -> &'static str { + "comments list" + } + + // Create multiple sub-routers with different routes + let users_router = Router::new() + .route("/", get(users_list)) + .route("/{id}", get(users_get)); + + let posts_router = Router::new() + .route("/", get(posts_list)) + .route("/{id}", get(posts_get)); + + let comments_router = Router::new().route("/", get(comments_list)); + + // Nest all routers under different prefixes + let app = Router::new() + .nest("/api/users", users_router) + .nest("/api/posts", posts_router) + .nest("/api/comments", comments_router); + + // Verify all routes are registered (2 + 2 + 1 = 5 routes) + let routes = app.registered_routes(); + assert_eq!(routes.len(), 5, "Should have 5 routes registered"); + + // Verify users routes + assert!( + routes.contains_key("/api/users"), + "Should have /api/users route" + ); + assert!( + routes.contains_key("/api/users/:id"), + "Should have /api/users/:id route" + ); + + // Verify posts routes + assert!( + routes.contains_key("/api/posts"), + "Should have /api/posts route" + ); + assert!( + routes.contains_key("/api/posts/:id"), + "Should have /api/posts/:id route" + ); + + // Verify comments routes + assert!( + routes.contains_key("/api/comments"), + "Should have /api/comments route" + ); +} + +#[test] +fn test_multiple_router_composition_no_interference() { + async fn users_handler() -> &'static str { + "users" + } + async fn posts_handler() -> &'static str { + "posts" + } + async fn admin_handler() -> &'static str { + "admin" + } + + // Create routers with same internal structure but different prefixes + let users_router = Router::new() + .route("/list", get(users_handler)) + .route("/{id}", get(users_handler)); + + let posts_router = Router::new() + .route("/list", get(posts_handler)) + .route("/{id}", get(posts_handler)); + + let admin_router = Router::new() + .route("/list", get(admin_handler)) + .route("/{id}", get(admin_handler)); + + // Nest all routers + let app = Router::new() + .nest("/api/v1/users", users_router) + .nest("/api/v1/posts", posts_router) + .nest("/admin", admin_router); + + // Verify all routes are registered (2 + 2 + 2 = 6 routes) + let routes = app.registered_routes(); + assert_eq!(routes.len(), 6, "Should have 6 routes registered"); + + // Verify each prefix group has its routes + assert!(routes.contains_key("/api/v1/users/list")); + assert!(routes.contains_key("/api/v1/users/:id")); + assert!(routes.contains_key("/api/v1/posts/list")); + assert!(routes.contains_key("/api/v1/posts/:id")); + assert!(routes.contains_key("/admin/list")); + assert!(routes.contains_key("/admin/:id")); + + // Verify routes are matchable and don't interfere with each other + match app.match_route("/api/v1/users/list", &Method::GET) { + RouteMatch::Found { params, .. } => { + assert!(params.is_empty(), "Static path should have no params"); + } + _ => panic!("Should find /api/v1/users/list"), + } + + match app.match_route("/api/v1/posts/123", &Method::GET) { + RouteMatch::Found { params, .. } => { + assert_eq!(params.get("id"), Some(&"123".to_string())); + } + _ => panic!("Should find /api/v1/posts/123"), + } + + match app.match_route("/admin/456", &Method::GET) { + RouteMatch::Found { params, .. } => { + assert_eq!(params.get("id"), Some(&"456".to_string())); + } + _ => panic!("Should find /admin/456"), + } +} + +#[test] +fn test_multiple_router_composition_with_multiple_methods() { + async fn get_handler() -> &'static str { + "get" + } + async fn post_handler() -> &'static str { + "post" + } + async fn put_handler() -> &'static str { + "put" + } + + // Create routers with multiple HTTP methods + // Combine GET and POST for users root + let get_router = get(get_handler); + let post_router = post(post_handler); + let mut users_root_combined = MethodRouter::new(); + for (method, handler) in get_router.handlers { + users_root_combined.handlers.insert(method, handler); + } + for (method, handler) in post_router.handlers { + users_root_combined.handlers.insert(method, handler); + } + + // Combine GET and PUT for users/{id} + let get_router2 = get(get_handler); + let put_router = put(put_handler); + let mut users_id_combined = MethodRouter::new(); + for (method, handler) in get_router2.handlers { + users_id_combined.handlers.insert(method, handler); + } + for (method, handler) in put_router.handlers { + users_id_combined.handlers.insert(method, handler); + } + + let users_router = Router::new() + .route("/", users_root_combined) + .route("/{id}", users_id_combined); + + // Combine GET and POST for posts root + let get_router3 = get(get_handler); + let post_router2 = post(post_handler); + let mut posts_root_combined = MethodRouter::new(); + for (method, handler) in get_router3.handlers { + posts_root_combined.handlers.insert(method, handler); + } + for (method, handler) in post_router2.handlers { + posts_root_combined.handlers.insert(method, handler); + } + + let posts_router = Router::new().route("/", posts_root_combined); + + // Nest routers + let app = Router::new() + .nest("/users", users_router) + .nest("/posts", posts_router); + + // Verify routes are registered + let routes = app.registered_routes(); + assert_eq!(routes.len(), 3, "Should have 3 routes registered"); + + // Verify methods are preserved for users routes + let users_root = routes.get("/users").unwrap(); + assert!(users_root.methods.contains(&Method::GET)); + assert!(users_root.methods.contains(&Method::POST)); + + let users_id = routes.get("/users/:id").unwrap(); + assert!(users_id.methods.contains(&Method::GET)); + assert!(users_id.methods.contains(&Method::PUT)); + + // Verify methods are preserved for posts routes + let posts_root = routes.get("/posts").unwrap(); + assert!(posts_root.methods.contains(&Method::GET)); + assert!(posts_root.methods.contains(&Method::POST)); + + // Verify route matching works for all methods + match app.match_route("/users", &Method::GET) { + RouteMatch::Found { .. } => {} + _ => panic!("GET /users should be found"), + } + match app.match_route("/users", &Method::POST) { + RouteMatch::Found { .. } => {} + _ => panic!("POST /users should be found"), + } + match app.match_route("/users/123", &Method::PUT) { + RouteMatch::Found { .. } => {} + _ => panic!("PUT /users/123 should be found"), + } +} + +#[test] +fn test_multiple_router_composition_deep_nesting() { + async fn handler() -> &'static str { + "handler" + } + + // Create nested routers at different depth levels + let deep_router = Router::new().route("/action", get(handler)); + + let mid_router = Router::new().route("/info", get(handler)); + + let shallow_router = Router::new().route("/status", get(handler)); + + // Nest at different depths + let app = Router::new() + .nest("/api/v1/resources/items", deep_router) + .nest("/api/v1/resources", mid_router) + .nest("/api", shallow_router); + + // Verify all routes are registered + let routes = app.registered_routes(); + assert_eq!(routes.len(), 3, "Should have 3 routes registered"); + + assert!(routes.contains_key("/api/v1/resources/items/action")); + assert!(routes.contains_key("/api/v1/resources/info")); + assert!(routes.contains_key("/api/status")); + + // Verify all routes are matchable + match app.match_route("/api/v1/resources/items/action", &Method::GET) { + RouteMatch::Found { .. } => {} + _ => panic!("Should find deep route"), + } + match app.match_route("/api/v1/resources/info", &Method::GET) { + RouteMatch::Found { .. } => {} + _ => panic!("Should find mid route"), + } + match app.match_route("/api/status", &Method::GET) { + RouteMatch::Found { .. } => {} + _ => panic!("Should find shallow route"), + } +} + +// **Feature: router-nesting, Property 2: Prefix Normalization** +// +// For any prefix string (with or without leading/trailing slashes), the normalized +// prefix should start with exactly one slash and have no trailing slash, and all +// nested routes should have properly formed paths without double slashes. +// +// **Validates: Requirements 1.2, 1.3** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Normalized prefix always starts with exactly one slash + /// + /// For any input prefix, the normalized result should always start with + /// exactly one leading slash. + #[test] + fn prop_normalized_prefix_starts_with_single_slash( + // Generate prefix with optional leading slashes + leading_slashes in prop::collection::vec(Just('/'), 0..5), + segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 0..4), + trailing_slashes in prop::collection::vec(Just('/'), 0..5), + ) { + // Build the input prefix + let mut prefix = String::new(); + for _ in &leading_slashes { + prefix.push('/'); + } + for (i, segment) in segments.iter().enumerate() { + if i > 0 { + prefix.push('/'); + } + prefix.push_str(segment); + } + for _ in &trailing_slashes { + prefix.push('/'); + } + + let normalized = normalize_prefix(&prefix); + + // Property 1: Always starts with exactly one slash + prop_assert!( + normalized.starts_with('/'), + "Normalized prefix '{}' should start with '/', input was '{}'", + normalized, prefix + ); + + // Property 2: No double slashes at the start + prop_assert!( + !normalized.starts_with("//"), + "Normalized prefix '{}' should not start with '//', input was '{}'", + normalized, prefix + ); + } + + /// Property: Normalized prefix has no trailing slash (unless root) + /// + /// For any input prefix with non-empty segments, the normalized result + /// should have no trailing slash. + #[test] + fn prop_normalized_prefix_no_trailing_slash( + segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4), + trailing_slashes in prop::collection::vec(Just('/'), 0..5), + ) { + // Build the input prefix with segments + let mut prefix = String::from("/"); + for (i, segment) in segments.iter().enumerate() { + if i > 0 { + prefix.push('/'); + } + prefix.push_str(segment); + } + for _ in &trailing_slashes { + prefix.push('/'); + } + + let normalized = normalize_prefix(&prefix); + + // Property: No trailing slash when there are segments + prop_assert!( + !normalized.ends_with('/'), + "Normalized prefix '{}' should not end with '/', input was '{}'", + normalized, prefix + ); + } + + /// Property: Normalized prefix has no double slashes + /// + /// For any input prefix, the normalized result should never contain + /// consecutive slashes. + #[test] + fn prop_normalized_prefix_no_double_slashes( + // Generate prefix with random slashes between segments + segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4), + extra_slashes in prop::collection::vec(0..4usize, 1..4), + ) { + // Build the input prefix with extra slashes between segments + let mut prefix = String::from("/"); + for (i, segment) in segments.iter().enumerate() { + if i > 0 { + // Add extra slashes between segments + let num_slashes = extra_slashes.get(i).copied().unwrap_or(1); + for _ in 0..=num_slashes { + prefix.push('/'); + } + } + prefix.push_str(segment); + } + + let normalized = normalize_prefix(&prefix); + + // Property: No consecutive slashes + prop_assert!( + !normalized.contains("//"), + "Normalized prefix '{}' should not contain '//', input was '{}'", + normalized, prefix + ); + } + + /// Property: Prefix normalization preserves segment content + /// + /// For any input prefix, all non-empty segments should be preserved + /// in the normalized output in the same order. + #[test] + fn prop_normalized_prefix_preserves_segments( + segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..4), + ) { + // Build the input prefix + let prefix = format!("/{}", segments.join("/")); + + let normalized = normalize_prefix(&prefix); + + // Extract segments from normalized prefix + let normalized_segments: Vec<&str> = normalized + .split('/') + .filter(|s| !s.is_empty()) + .collect(); + + prop_assert_eq!( + segments.len(), + normalized_segments.len(), + "Segment count should be preserved" + ); + + for (original, normalized_seg) in segments.iter().zip(normalized_segments.iter()) { + prop_assert_eq!( + original, normalized_seg, + "Segment content should be preserved" + ); + } + } + + /// Property: Empty or slash-only input normalizes to root + /// + /// For any input that contains only slashes or is empty, the normalized + /// result should be exactly "/". + #[test] + fn prop_empty_or_slashes_normalize_to_root( + num_slashes in 0..10usize, + ) { + let prefix = "/".repeat(num_slashes); + + let normalized = normalize_prefix(&prefix); + + prop_assert_eq!( + normalized, "/", + "Empty or slash-only prefix '{}' should normalize to '/'", + prefix + ); + } +} + +// **Feature: router-nesting, Property 3: HTTP Method Preservation** +// +// For any router with routes having multiple HTTP methods, cloning the MethodRouter +// should preserve all method handlers for each route. +// +// **Validates: Requirements 1.4** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Cloning a MethodRouter preserves all HTTP method handlers + /// + /// For any combination of HTTP methods registered on a MethodRouter, + /// cloning should preserve all handlers and their associated methods. + #[test] + fn prop_method_router_clone_preserves_methods( + // Generate a random subset of HTTP methods to register + use_get in any::(), + use_post in any::(), + use_put in any::(), + use_patch in any::(), + use_delete in any::(), + ) { + // Ensure at least one method is selected + prop_assume!(use_get || use_post || use_put || use_patch || use_delete); + + // Build a MethodRouter with the selected methods + let mut method_router = MethodRouter::new(); + let mut expected_methods: Vec = Vec::new(); + + async fn handler() -> &'static str { "handler" } + + if use_get { + method_router = get(handler); + expected_methods.push(Method::GET); + } + + if use_post { + let post_router = post(handler); + for (method, handler) in post_router.handlers { + method_router.handlers.insert(method.clone(), handler); + if !expected_methods.contains(&method) { + expected_methods.push(method); + } + } + } + + if use_put { + let put_router = put(handler); + for (method, handler) in put_router.handlers { + method_router.handlers.insert(method.clone(), handler); + if !expected_methods.contains(&method) { + expected_methods.push(method); + } + } + } + + if use_patch { + let patch_router = patch(handler); + for (method, handler) in patch_router.handlers { + method_router.handlers.insert(method.clone(), handler); + if !expected_methods.contains(&method) { + expected_methods.push(method); + } + } + } + + if use_delete { + let delete_router = delete(handler); + for (method, handler) in delete_router.handlers { + method_router.handlers.insert(method.clone(), handler); + if !expected_methods.contains(&method) { + expected_methods.push(method); + } + } + } + + // Clone the MethodRouter + let cloned_router = method_router.clone(); + + // Verify all methods are preserved in the clone + let original_methods = method_router.allowed_methods(); + let cloned_methods = cloned_router.allowed_methods(); + + prop_assert_eq!( + original_methods.len(), + cloned_methods.len(), + "Cloned router should have same number of methods" + ); + + for method in &expected_methods { + prop_assert!( + cloned_router.get_handler(method).is_some(), + "Cloned router should have handler for method {:?}", + method + ); + } + + // Verify handlers are accessible (not null/invalid) + for method in &cloned_methods { + prop_assert!( + cloned_router.get_handler(method).is_some(), + "Handler for {:?} should be accessible after clone", + method + ); + } + } +} + +// **Feature: router-nesting, Property 1: Route Registration with Prefix** +// +// For any router with routes and any valid prefix, nesting the router should +// result in all routes being registered with the prefix prepended to their +// original paths. +// +// **Validates: Requirements 1.1** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: All nested routes are registered with prefix prepended + /// + /// For any router with routes and any valid prefix, nesting should result + /// in all routes being registered with the prefix prepended. + #[test] + fn prop_nested_routes_have_prefix( + // Generate prefix segments + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), + // Generate route path segments + route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), + has_param in any::(), + ) { + async fn handler() -> &'static str { "handler" } + + // Build the prefix + let prefix = format!("/{}", prefix_segments.join("/")); + + // Build the route path + let mut route_path = format!("/{}", route_segments.join("/")); + if has_param { + route_path.push_str("/{id}"); + } + + // Create nested router and nest it + let nested_router = Router::new().route(&route_path, get(handler)); + let app = Router::new().nest(&prefix, nested_router); + + // Build expected prefixed path (matchit format) + let expected_matchit_path = if has_param { + format!("{}/{}/:id", prefix, route_segments.join("/")) + } else { + format!("{}/{}", prefix, route_segments.join("/")) + }; + + let routes = app.registered_routes(); + + // Property: The prefixed route should exist + prop_assert!( + routes.contains_key(&expected_matchit_path), + "Expected route '{}' not found. Available routes: {:?}", + expected_matchit_path, + routes.keys().collect::>() + ); + + // Property: The route info should have the correct display path + let route_info = routes.get(&expected_matchit_path).unwrap(); + let expected_display_path = format!("{}{}", prefix, route_path); + prop_assert_eq!( + &route_info.path, &expected_display_path, + "Display path should be prefix + original path" + ); + } + + /// Property: Number of routes is preserved after nesting + /// + /// For any router with N routes, nesting should result in exactly N routes + /// being registered in the parent router (assuming no conflicts). + #[test] + fn prop_route_count_preserved_after_nesting( + // Generate number of routes (1-3 to keep test fast) + num_routes in 1..4usize, + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), + ) { + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix_segments.join("/")); + + // Create nested router with multiple routes + let mut nested_router = Router::new(); + for i in 0..num_routes { + let path = format!("/route{}", i); + nested_router = nested_router.route(&path, get(handler)); + } + + let app = Router::new().nest(&prefix, nested_router); + + prop_assert_eq!( + app.registered_routes().len(), + num_routes, + "Number of routes should be preserved after nesting" + ); + } + + /// Property: Nested routes are matchable + /// + /// For any nested route, a request to the prefixed path should match. + #[test] + fn prop_nested_routes_are_matchable( + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + ) { + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix_segments.join("/")); + let route_path = format!("/{}", route_segments.join("/")); + + let nested_router = Router::new().route(&route_path, get(handler)); + let app = Router::new().nest(&prefix, nested_router); + + // Build the full path to match + let full_path = format!("{}{}", prefix, route_path); + + // Property: The route should be matchable + match app.match_route(&full_path, &Method::GET) { + RouteMatch::Found { .. } => { + // Success - route was found + } + RouteMatch::NotFound => { + prop_assert!(false, "Route '{}' should be found but got NotFound", full_path); + } + RouteMatch::MethodNotAllowed { .. } => { + prop_assert!(false, "Route '{}' should be found but got MethodNotAllowed", full_path); + } + } + } +} + +// **Feature: router-nesting, Property 9: State Merging** +// +// For any nested router with state, that state should be accessible via the +// State extractor in handlers after nesting (assuming no type conflict with parent). +// +// **Validates: Requirements 3.1, 3.3** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: State type IDs are merged from nested router + /// + /// For any nested router with state, the parent router should track + /// the state type IDs after nesting. + #[test] + fn prop_state_type_ids_merged( + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + has_nested_state in any::(), + ) { + #[derive(Clone)] + struct TestState(#[allow(dead_code)] i32); + + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix_segments.join("/")); + + let mut nested = Router::new().route("/test", get(handler)); + if has_nested_state { + nested = nested.state(TestState(42)); + } + + let parent = Router::new().nest(&prefix, nested); + + // Property: If nested had state, parent should track the type ID + if has_nested_state { + prop_assert!( + parent.state_type_ids().contains(&std::any::TypeId::of::()), + "Parent should track nested state type ID" + ); + } + } + + /// Property: State merging adds nested state to parent + /// + /// For any nested router with state that the parent doesn't have, + /// merge_state should add that state to the parent. + #[test] + fn prop_merge_state_adds_nested_state( + state_value in any::(), + ) { + #[derive(Clone, PartialEq, Debug)] + struct UniqueState(i32); + + // Create a source router with state + let source = Router::new().state(UniqueState(state_value)); + + // Create a parent without this state type + let parent = Router::new().merge_state::(&source); + + // Property: Parent should now have the state + prop_assert!( + parent.has_state::(), + "Parent should have state after merge" + ); + + // Property: State value should match + let merged_state = parent.state.get::().unwrap(); + prop_assert_eq!( + merged_state.0, state_value, + "Merged state value should match source" + ); + } +} + +// **Feature: router-nesting, Property 10: State Precedence** +// +// For any parent and nested router both having state of the same type, +// the parent's state value should be preserved after nesting. +// +// **Validates: Requirements 3.2** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Parent state takes precedence over nested state + /// + /// For any parent and nested router both having state of the same type, + /// the parent's state value should be preserved after merge_state. + #[test] + fn prop_parent_state_takes_precedence( + parent_value in any::(), + nested_value in any::(), + ) { + // Ensure values are different to make the test meaningful + prop_assume!(parent_value != nested_value); + + #[derive(Clone, PartialEq, Debug)] + struct SharedState(i32); + + // Create source router with nested state + let source = Router::new().state(SharedState(nested_value)); + + // Create parent with its own state + let parent = Router::new() + .state(SharedState(parent_value)) + .merge_state::(&source); + + // Property: Parent should still have state + prop_assert!( + parent.has_state::(), + "Parent should have state" + ); + + // Property: Parent's state value should be preserved (parent wins) + let final_state = parent.state.get::().unwrap(); + prop_assert_eq!( + final_state.0, parent_value, + "Parent state value should be preserved, not overwritten by nested" + ); + } + + /// Property: State precedence is consistent regardless of merge order + /// + /// For any parent with state, merging from a source with the same type + /// should always preserve the parent's value. + #[test] + fn prop_state_precedence_consistent( + parent_value in any::(), + source1_value in any::(), + source2_value in any::(), + ) { + #[derive(Clone, PartialEq, Debug)] + struct ConsistentState(i32); + + // Create multiple source routers + let source1 = Router::new().state(ConsistentState(source1_value)); + let source2 = Router::new().state(ConsistentState(source2_value)); + + // Create parent with its own state and merge from multiple sources + let parent = Router::new() + .state(ConsistentState(parent_value)) + .merge_state::(&source1) + .merge_state::(&source2); + + // Property: Parent's original state should be preserved + let final_state = parent.state.get::().unwrap(); + prop_assert_eq!( + final_state.0, parent_value, + "Parent state should be preserved after multiple merges" + ); + } +} + +// **Feature: phase4-ergonomics-v1, Property 1: Route Conflict Detection** +// +// For any two routes with the same path and HTTP method registered on the same +// RustApi instance, the system should detect the conflict and report an error +// at startup time. +// +// **Validates: Requirements 1.2** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Routes with identical path structure but different parameter names conflict + /// + /// For any valid path with parameters, registering two routes with the same + /// structure but different parameter names should be detected as a conflict. + #[test] + fn prop_same_structure_different_param_names_conflict( + // Generate valid path segments + segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4), + // Generate two different parameter names + param1 in "[a-z][a-z0-9]{0,5}", + param2 in "[a-z][a-z0-9]{0,5}", + ) { + // Ensure param names are different + prop_assume!(param1 != param2); + + // Build two paths with same structure but different param names + let mut path1 = String::from("/"); + let mut path2 = String::from("/"); + + for segment in &segments { + path1.push_str(segment); + path1.push('/'); + path2.push_str(segment); + path2.push('/'); + } + + path1.push('{'); + path1.push_str(¶m1); + path1.push('}'); + + path2.push('{'); + path2.push_str(¶m2); + path2.push('}'); + + // Try to register both routes - should panic + let result = catch_unwind(AssertUnwindSafe(|| { + async fn handler1() -> &'static str { "handler1" } + async fn handler2() -> &'static str { "handler2" } + + let _router = Router::new() + .route(&path1, get(handler1)) + .route(&path2, get(handler2)); + })); + + prop_assert!( + result.is_err(), + "Routes '{}' and '{}' should conflict but didn't", + path1, path2 + ); + } + + /// Property: Routes with different path structures don't conflict + /// + /// For any two paths with different structures (different number of segments + /// or different static segments), they should not conflict. + #[test] + fn prop_different_structures_no_conflict( + // Generate different path segments for two routes + segments1 in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), + segments2 in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), + // Optional parameter at the end + has_param1 in any::(), + has_param2 in any::(), + ) { + // Build two paths + let mut path1 = String::from("/"); + let mut path2 = String::from("/"); + + for segment in &segments1 { + path1.push_str(segment); + path1.push('/'); + } + path1.pop(); // Remove trailing slash + + for segment in &segments2 { + path2.push_str(segment); + path2.push('/'); + } + path2.pop(); // Remove trailing slash + + if has_param1 { + path1.push_str("/{id}"); + } + + if has_param2 { + path2.push_str("/{id}"); + } + + // Normalize paths for comparison + let norm1 = normalize_path_for_comparison(&convert_path_params(&path1)); + let norm2 = normalize_path_for_comparison(&convert_path_params(&path2)); + + // Only test if paths are actually different + prop_assume!(norm1 != norm2); + + // Try to register both routes - should succeed + let result = catch_unwind(AssertUnwindSafe(|| { + async fn handler1() -> &'static str { "handler1" } + async fn handler2() -> &'static str { "handler2" } + + let router = Router::new() + .route(&path1, get(handler1)) + .route(&path2, get(handler2)); + + router.registered_routes().len() + })); + + prop_assert!( + result.is_ok(), + "Routes '{}' and '{}' should not conflict but did", + path1, path2 + ); + + if let Ok(count) = result { + prop_assert_eq!(count, 2, "Should have registered 2 routes"); + } + } + + /// Property: Conflict error message contains both route paths + /// + /// When a conflict is detected, the error message should include both + /// the existing route path and the new conflicting route path. + #[test] + fn prop_conflict_error_contains_both_paths( + // Generate a valid path segment + segment in "[a-z][a-z0-9]{1,5}", + param1 in "[a-z][a-z0-9]{1,5}", + param2 in "[a-z][a-z0-9]{1,5}", + ) { + prop_assume!(param1 != param2); + + let path1 = format!("/{}/{{{}}}", segment, param1); + let path2 = format!("/{}/{{{}}}", segment, param2); + + let result = catch_unwind(AssertUnwindSafe(|| { + async fn handler1() -> &'static str { "handler1" } + async fn handler2() -> &'static str { "handler2" } + + let _router = Router::new() + .route(&path1, get(handler1)) + .route(&path2, get(handler2)); + })); + + prop_assert!(result.is_err(), "Should have panicked due to conflict"); + + // Check that the panic message contains useful information + if let Err(panic_info) = result { + if let Some(msg) = panic_info.downcast_ref::() { + prop_assert!( + msg.contains("ROUTE CONFLICT DETECTED"), + "Error should contain 'ROUTE CONFLICT DETECTED', got: {}", + msg + ); + prop_assert!( + msg.contains("Existing:") && msg.contains("New:"), + "Error should contain both 'Existing:' and 'New:' labels, got: {}", + msg + ); + prop_assert!( + msg.contains("How to resolve:"), + "Error should contain resolution guidance, got: {}", + msg + ); + } + } + } + + /// Property: Exact duplicate paths conflict + /// + /// Registering the exact same path twice should always be detected as a conflict. + #[test] + fn prop_exact_duplicate_paths_conflict( + // Generate valid path segments + segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..4), + has_param in any::(), + ) { + // Build a path + let mut path = String::from("/"); + + for segment in &segments { + path.push_str(segment); + path.push('/'); + } + path.pop(); // Remove trailing slash + + if has_param { + path.push_str("/{id}"); + } + + // Try to register the same path twice - should panic + let result = catch_unwind(AssertUnwindSafe(|| { + async fn handler1() -> &'static str { "handler1" } + async fn handler2() -> &'static str { "handler2" } + + let _router = Router::new() + .route(&path, get(handler1)) + .route(&path, get(handler2)); + })); + + prop_assert!( + result.is_err(), + "Registering path '{}' twice should conflict but didn't", + path + ); + } +} + +// **Feature: router-nesting, Property 5: Nested Route Matching** +// +// For any nested route and a request with a matching path and method, +// the router should return the correct handler. +// +// **Validates: Requirements 2.1** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Nested routes with path parameters are correctly matched + /// + /// For any nested route with path parameters, a request to the prefixed path + /// with valid parameter values should match and return Found. + #[test] + fn prop_nested_route_with_params_matches( + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 0..2), + param_value in "[a-z0-9]{1,10}", + ) { + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix_segments.join("/")); + let route_path = if route_segments.is_empty() { + "/{id}".to_string() + } else { + format!("/{}/{{id}}", route_segments.join("/")) + }; + + let nested_router = Router::new().route(&route_path, get(handler)); + let app = Router::new().nest(&prefix, nested_router); + + // Build the full path to match with actual parameter value + let full_path = if route_segments.is_empty() { + format!("{}/{}", prefix, param_value) + } else { + format!("{}/{}/{}", prefix, route_segments.join("/"), param_value) + }; + + // Property: The route should be matched + match app.match_route(&full_path, &Method::GET) { + RouteMatch::Found { params, .. } => { + // Verify the parameter was extracted + prop_assert!( + params.contains_key("id"), + "Should have 'id' parameter, got: {:?}", + params + ); + prop_assert_eq!( + params.get("id").unwrap(), + ¶m_value, + "Parameter value should match" + ); + } + RouteMatch::NotFound => { + prop_assert!(false, "Route '{}' should be found but got NotFound", full_path); + } + RouteMatch::MethodNotAllowed { .. } => { + prop_assert!(false, "Route '{}' should be found but got MethodNotAllowed", full_path); + } + } + } + + /// Property: Nested routes match correct HTTP method + /// + /// For any nested route registered with a specific HTTP method, only requests + /// with that method should return Found. + #[test] + fn prop_nested_route_matches_correct_method( + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2), + route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..2), + use_get in any::(), + ) { + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix_segments.join("/")); + let route_path = format!("/{}", route_segments.join("/")); + + // Register with either GET or POST + let method_router = if use_get { get(handler) } else { post(handler) }; + let nested_router = Router::new().route(&route_path, method_router); + let app = Router::new().nest(&prefix, nested_router); + + let full_path = format!("{}{}", prefix, route_path); + let registered_method = if use_get { Method::GET } else { Method::POST }; + let other_method = if use_get { Method::POST } else { Method::GET }; + + // Property: Registered method should match + match app.match_route(&full_path, ®istered_method) { + RouteMatch::Found { .. } => { + // Success + } + other => { + prop_assert!(false, "Route should be found for registered method, got: {:?}", + match other { + RouteMatch::NotFound => "NotFound", + RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed", + _ => "Found", + } + ); + } + } + + // Property: Other method should return MethodNotAllowed + match app.match_route(&full_path, &other_method) { + RouteMatch::MethodNotAllowed { allowed } => { + prop_assert!( + allowed.contains(®istered_method), + "Allowed methods should contain {:?}", + registered_method + ); + } + other => { + prop_assert!(false, "Route should return MethodNotAllowed for other method, got: {:?}", + match other { + RouteMatch::NotFound => "NotFound", + RouteMatch::Found { .. } => "Found", + _ => "MethodNotAllowed", + } + ); + } + } + } +} + +// **Feature: router-nesting, Property 6: Path Parameter Extraction** +// +// For any nested route with path parameters and a matching request, +// the extracted parameters should have the correct names and values. +// +// **Validates: Requirements 2.2** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Single path parameter is correctly extracted from nested route + /// + /// For any nested route with a single path parameter, the parameter name + /// and value should be correctly extracted. + #[test] + fn prop_single_param_extraction( + prefix in "[a-z][a-z0-9]{1,5}", + param_name in "[a-z][a-z0-9]{1,5}", + param_value in "[a-z0-9]{1,10}", + ) { + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix); + let route_path = format!("/{{{}}}", param_name); + + let nested_router = Router::new().route(&route_path, get(handler)); + let app = Router::new().nest(&prefix, nested_router); + + let full_path = format!("{}/{}", prefix, param_value); + + match app.match_route(&full_path, &Method::GET) { + RouteMatch::Found { params, .. } => { + prop_assert!( + params.contains_key(¶m_name), + "Should have '{}' parameter, got: {:?}", + param_name, params + ); + prop_assert_eq!( + params.get(¶m_name).unwrap(), + ¶m_value, + "Parameter '{}' value should be '{}'", + param_name, param_value + ); + } + _ => { + prop_assert!(false, "Route should be found"); + } + } + } + + /// Property: Multiple path parameters are correctly extracted from nested route + /// + /// For any nested route with multiple path parameters, all parameters + /// should be correctly extracted with their names and values. + #[test] + fn prop_multiple_params_extraction( + prefix in "[a-z][a-z0-9]{1,5}", + param1_name in "[a-z]{1,5}", + param1_value in "[a-z0-9]{1,10}", + param2_name in "[a-z]{1,5}", + param2_value in "[a-z0-9]{1,10}", + ) { + // Ensure param names are different + prop_assume!(param1_name != param2_name); + + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix); + let route_path = format!("/{{{}}}/items/{{{}}}", param1_name, param2_name); + + let nested_router = Router::new().route(&route_path, get(handler)); + let app = Router::new().nest(&prefix, nested_router); + + let full_path = format!("{}/{}/items/{}", prefix, param1_value, param2_value); + + match app.match_route(&full_path, &Method::GET) { + RouteMatch::Found { params, .. } => { + // Check first parameter + prop_assert!( + params.contains_key(¶m1_name), + "Should have '{}' parameter, got: {:?}", + param1_name, params + ); + prop_assert_eq!( + params.get(¶m1_name).unwrap(), + ¶m1_value, + "Parameter '{}' value should be '{}'", + param1_name, param1_value + ); + + // Check second parameter + prop_assert!( + params.contains_key(¶m2_name), + "Should have '{}' parameter, got: {:?}", + param2_name, params + ); + prop_assert_eq!( + params.get(¶m2_name).unwrap(), + ¶m2_value, + "Parameter '{}' value should be '{}'", + param2_name, param2_value + ); + } + _ => { + prop_assert!(false, "Route should be found"); + } + } + } + + /// Property: Path parameters preserve special characters in values + /// + /// For any nested route with path parameters, parameter values containing + /// URL-safe special characters should be preserved correctly. + #[test] + fn prop_param_value_preservation( + prefix in "[a-z]{1,5}", + // Generate values with alphanumeric and some special chars + param_value in "[a-zA-Z0-9_-]{1,15}", + ) { + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix); + let route_path = "/{id}".to_string(); + + let nested_router = Router::new().route(&route_path, get(handler)); + let app = Router::new().nest(&prefix, nested_router); + + let full_path = format!("{}/{}", prefix, param_value); + + match app.match_route(&full_path, &Method::GET) { + RouteMatch::Found { params, .. } => { + prop_assert_eq!( + params.get("id").unwrap(), + ¶m_value, + "Parameter value should be preserved exactly" + ); + } + _ => { + prop_assert!(false, "Route should be found"); + } + } + } +} + +// **Feature: router-nesting, Property 7: Not Found Response** +// +// For any request path that doesn't match any registered route (nested or otherwise), +// the router should return NotFound. +// +// **Validates: Requirements 2.3** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Unregistered paths return NotFound + /// + /// For any path that doesn't match any registered route, the router + /// should return NotFound. + #[test] + fn prop_unregistered_path_returns_not_found( + prefix in "[a-z][a-z0-9]{1,5}", + route_segment in "[a-z][a-z0-9]{1,5}", + unregistered_segment in "[a-z][a-z0-9]{6,10}", + ) { + // Ensure segments are different + prop_assume!(route_segment != unregistered_segment); + + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix); + let route_path = format!("/{}", route_segment); + + let nested_router = Router::new().route(&route_path, get(handler)); + let app = Router::new().nest(&prefix, nested_router); + + // Try to match an unregistered path + let unregistered_path = format!("{}/{}", prefix, unregistered_segment); + + match app.match_route(&unregistered_path, &Method::GET) { + RouteMatch::NotFound => { + // Success - this is expected + } + RouteMatch::Found { .. } => { + prop_assert!(false, "Path '{}' should not be found", unregistered_path); + } + RouteMatch::MethodNotAllowed { .. } => { + prop_assert!(false, "Path '{}' should return NotFound, not MethodNotAllowed", unregistered_path); + } + } + } + + /// Property: Wrong prefix returns NotFound + /// + /// For any nested route, a request with a different prefix should return NotFound. + #[test] + fn prop_wrong_prefix_returns_not_found( + prefix1 in "[a-z][a-z0-9]{1,5}", + prefix2 in "[a-z][a-z0-9]{6,10}", + route_segment in "[a-z][a-z0-9]{1,5}", + ) { + // Ensure prefixes are different + prop_assume!(prefix1 != prefix2); + + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix1); + let route_path = format!("/{}", route_segment); + + let nested_router = Router::new().route(&route_path, get(handler)); + let app = Router::new().nest(&prefix, nested_router); + + // Try to match with wrong prefix + let wrong_prefix_path = format!("/{}/{}", prefix2, route_segment); + + match app.match_route(&wrong_prefix_path, &Method::GET) { + RouteMatch::NotFound => { + // Success - this is expected + } + _ => { + prop_assert!(false, "Path '{}' with wrong prefix should return NotFound", wrong_prefix_path); + } + } + } + + /// Property: Partial path match returns NotFound + /// + /// For any nested route with multiple segments, a request matching only + /// part of the path should return NotFound. + #[test] + fn prop_partial_path_returns_not_found( + prefix in "[a-z][a-z0-9]{1,5}", + segment1 in "[a-z][a-z0-9]{1,5}", + segment2 in "[a-z][a-z0-9]{1,5}", + ) { + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix); + let route_path = format!("/{}/{}", segment1, segment2); + + let nested_router = Router::new().route(&route_path, get(handler)); + let app = Router::new().nest(&prefix, nested_router); + + // Try to match only the first segment (partial path) + let partial_path = format!("{}/{}", prefix, segment1); + + match app.match_route(&partial_path, &Method::GET) { + RouteMatch::NotFound => { + // Success - partial path should not match + } + _ => { + prop_assert!(false, "Partial path '{}' should return NotFound", partial_path); + } + } + } +} + +// **Feature: router-nesting, Property 8: Method Not Allowed Response** +// +// For any request to a valid path but with an unregistered HTTP method, +// the router should return MethodNotAllowed with the list of allowed methods. +// +// **Validates: Requirements 2.4** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Unregistered method returns MethodNotAllowed with allowed methods + /// + /// For any nested route registered with specific methods, a request with + /// an unregistered method should return MethodNotAllowed with the correct + /// list of allowed methods. + #[test] + fn prop_unregistered_method_returns_method_not_allowed( + prefix in "[a-z][a-z0-9]{1,5}", + route_segment in "[a-z][a-z0-9]{1,5}", + ) { + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix); + let route_path = format!("/{}", route_segment); + + // Register only GET + let nested_router = Router::new().route(&route_path, get(handler)); + let app = Router::new().nest(&prefix, nested_router); + + let full_path = format!("{}{}", prefix, route_path); + + // Try POST on a GET-only route + match app.match_route(&full_path, &Method::POST) { + RouteMatch::MethodNotAllowed { allowed } => { + prop_assert!( + allowed.contains(&Method::GET), + "Allowed methods should contain GET, got: {:?}", + allowed + ); + prop_assert!( + !allowed.contains(&Method::POST), + "Allowed methods should not contain POST" + ); + } + RouteMatch::Found { .. } => { + prop_assert!(false, "POST should not be found on GET-only route"); + } + RouteMatch::NotFound => { + prop_assert!(false, "Path exists, should return MethodNotAllowed not NotFound"); + } + } + } + + /// Property: Multiple registered methods are all returned in allowed list + /// + /// For any nested route registered with multiple methods, the MethodNotAllowed + /// response should include all registered methods. + #[test] + fn prop_multiple_methods_in_allowed_list( + prefix in "[a-z][a-z0-9]{1,5}", + route_segment in "[a-z][a-z0-9]{1,5}", + use_get in any::(), + use_post in any::(), + use_put in any::(), + ) { + // Ensure at least one method is registered + prop_assume!(use_get || use_post || use_put); + + async fn handler() -> &'static str { "handler" } + + let prefix = format!("/{}", prefix); + let route_path = format!("/{}", route_segment); + + // Build method router with selected methods + let mut method_router = MethodRouter::new(); + let mut expected_methods: Vec = Vec::new(); + + if use_get { + let get_router = get(handler); + for (method, h) in get_router.handlers { + method_router.handlers.insert(method.clone(), h); + expected_methods.push(method); + } + } + if use_post { + let post_router = post(handler); + for (method, h) in post_router.handlers { + method_router.handlers.insert(method.clone(), h); + expected_methods.push(method); + } + } + if use_put { + let put_router = put(handler); + for (method, h) in put_router.handlers { + method_router.handlers.insert(method.clone(), h); + expected_methods.push(method); + } + } + + let nested_router = Router::new().route(&route_path, method_router); + let app = Router::new().nest(&prefix, nested_router); + + let full_path = format!("{}{}", prefix, route_path); + + // Try DELETE (which we never register) + match app.match_route(&full_path, &Method::DELETE) { + RouteMatch::MethodNotAllowed { allowed } => { + // All registered methods should be in allowed list + for method in &expected_methods { + prop_assert!( + allowed.contains(method), + "Allowed methods should contain {:?}, got: {:?}", + method, allowed + ); + } + // DELETE should not be in allowed list + prop_assert!( + !allowed.contains(&Method::DELETE), + "Allowed methods should not contain DELETE" + ); + } + RouteMatch::Found { .. } => { + prop_assert!(false, "DELETE should not be found"); + } + RouteMatch::NotFound => { + prop_assert!(false, "Path exists, should return MethodNotAllowed not NotFound"); + } + } + } +} + +// **Feature: router-nesting, Property 12: Conflict Detection** +// +// For any nested route that conflicts with an existing route (same path structure), +// the router should detect and report the conflict with both route paths. +// +// **Validates: Requirements 5.1, 5.3** + +// **Feature: router-nesting, Property 4: Multiple Router Composition** +// +// For any set of routers with non-overlapping route structures nested under +// different prefixes, all routes should be registered without conflicts. +// +// **Validates: Requirements 1.5** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Multiple routers nested under different prefixes register all routes + /// + /// For any set of routers with routes nested under different prefixes, + /// all routes should be registered and the total count should equal the + /// sum of routes from all nested routers. + #[test] + fn prop_multiple_routers_all_routes_registered( + // Generate 2-3 different prefixes + prefix1_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + prefix2_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + // Generate route counts for each router (1-3 routes each) + num_routes1 in 1..4usize, + num_routes2 in 1..4usize, + ) { + // Build prefixes + let prefix1 = format!("/{}", prefix1_segments.join("/")); + let prefix2 = format!("/{}", prefix2_segments.join("/")); + + // Ensure prefixes are different + prop_assume!(prefix1 != prefix2); + + async fn handler() -> &'static str { "handler" } + + // Create first router with routes + let mut router1 = Router::new(); + for i in 0..num_routes1 { + let path = format!("/route1_{}", i); + router1 = router1.route(&path, get(handler)); + } + + // Create second router with routes + let mut router2 = Router::new(); + for i in 0..num_routes2 { + let path = format!("/route2_{}", i); + router2 = router2.route(&path, get(handler)); + } + + // Nest both routers + let app = Router::new() + .nest(&prefix1, router1) + .nest(&prefix2, router2); + + let routes = app.registered_routes(); + + // Property: Total route count should equal sum of all nested routes + let expected_count = num_routes1 + num_routes2; + prop_assert_eq!( + routes.len(), + expected_count, + "Should have {} routes ({}+{}), got {}", + expected_count, num_routes1, num_routes2, routes.len() + ); + + // Property: All routes from router1 should be registered with prefix1 + for i in 0..num_routes1 { + let expected_path = format!("{}/route1_{}", prefix1, i); + let matchit_path = convert_path_params(&expected_path); + prop_assert!( + routes.contains_key(&matchit_path), + "Route '{}' should be registered", + expected_path + ); + } + + // Property: All routes from router2 should be registered with prefix2 + for i in 0..num_routes2 { + let expected_path = format!("{}/route2_{}", prefix2, i); + let matchit_path = convert_path_params(&expected_path); + prop_assert!( + routes.contains_key(&matchit_path), + "Route '{}' should be registered", + expected_path + ); + } + } + + /// Property: Multiple routers with same internal routes don't interfere + /// + /// For any set of routers with identical internal route structures nested + /// under different prefixes, all routes should be independently matchable. + #[test] + fn prop_multiple_routers_no_interference( + prefix1 in "[a-z][a-z0-9]{1,5}", + prefix2 in "[a-z][a-z0-9]{1,5}", + route_segment in "[a-z][a-z0-9]{1,5}", + param_value1 in "[a-z0-9]{1,10}", + param_value2 in "[a-z0-9]{1,10}", + ) { + // Ensure prefixes are different + prop_assume!(prefix1 != prefix2); + + let prefix1 = format!("/{}", prefix1); + let prefix2 = format!("/{}", prefix2); + + async fn handler() -> &'static str { "handler" } + + // Create two routers with identical internal structure + let router1 = Router::new() + .route(&format!("/{}", route_segment), get(handler)) + .route("/{id}", get(handler)); + + let router2 = Router::new() + .route(&format!("/{}", route_segment), get(handler)) + .route("/{id}", get(handler)); + + // Nest both routers + let app = Router::new() + .nest(&prefix1, router1) + .nest(&prefix2, router2); + + // Property: Routes under prefix1 should be matchable + let path1_static = format!("{}/{}", prefix1, route_segment); + match app.match_route(&path1_static, &Method::GET) { + RouteMatch::Found { params, .. } => { + prop_assert!(params.is_empty(), "Static path should have no params"); + } + _ => { + prop_assert!(false, "Route '{}' should be found", path1_static); + } + } + + let path1_param = format!("{}/{}", prefix1, param_value1); + match app.match_route(&path1_param, &Method::GET) { + RouteMatch::Found { params, .. } => { + prop_assert_eq!( + params.get("id"), + Some(¶m_value1.to_string()), + "Parameter should be extracted correctly" + ); + } + _ => { + prop_assert!(false, "Route '{}' should be found", path1_param); + } + } + + // Property: Routes under prefix2 should be matchable independently + let path2_static = format!("{}/{}", prefix2, route_segment); + match app.match_route(&path2_static, &Method::GET) { + RouteMatch::Found { params, .. } => { + prop_assert!(params.is_empty(), "Static path should have no params"); + } + _ => { + prop_assert!(false, "Route '{}' should be found", path2_static); + } + } + + let path2_param = format!("{}/{}", prefix2, param_value2); + match app.match_route(&path2_param, &Method::GET) { + RouteMatch::Found { params, .. } => { + prop_assert_eq!( + params.get("id"), + Some(¶m_value2.to_string()), + "Parameter should be extracted correctly" + ); + } + _ => { + prop_assert!(false, "Route '{}' should be found", path2_param); + } + } + } + + /// Property: Multiple routers preserve HTTP methods independently + /// + /// For any set of routers with different HTTP methods nested under different + /// prefixes, each route should preserve its own set of allowed methods. + #[test] + fn prop_multiple_routers_preserve_methods( + prefix1 in "[a-z][a-z0-9]{1,5}", + prefix2 in "[a-z][a-z0-9]{1,5}", + route_segment in "[a-z][a-z0-9]{1,5}", + router1_use_get in any::(), + router1_use_post in any::(), + router2_use_get in any::(), + router2_use_put in any::(), + ) { + // Ensure at least one method per router + prop_assume!(router1_use_get || router1_use_post); + prop_assume!(router2_use_get || router2_use_put); + // Ensure prefixes are different + prop_assume!(prefix1 != prefix2); + + let prefix1 = format!("/{}", prefix1); + let prefix2 = format!("/{}", prefix2); + let route_path = format!("/{}", route_segment); + + async fn handler() -> &'static str { "handler" } + + // Build router1 with selected methods + let mut method_router1 = MethodRouter::new(); + let mut expected_methods1: Vec = Vec::new(); + if router1_use_get { + let get_router = get(handler); + for (method, h) in get_router.handlers { + method_router1.handlers.insert(method.clone(), h); + expected_methods1.push(method); + } + } + if router1_use_post { + let post_router = post(handler); + for (method, h) in post_router.handlers { + method_router1.handlers.insert(method.clone(), h); + expected_methods1.push(method); + } + } + + // Build router2 with selected methods + let mut method_router2 = MethodRouter::new(); + let mut expected_methods2: Vec = Vec::new(); + if router2_use_get { + let get_router = get(handler); + for (method, h) in get_router.handlers { + method_router2.handlers.insert(method.clone(), h); + expected_methods2.push(method); + } + } + if router2_use_put { + let put_router = put(handler); + for (method, h) in put_router.handlers { + method_router2.handlers.insert(method.clone(), h); + expected_methods2.push(method); + } + } + + let router1 = Router::new().route(&route_path, method_router1); + let router2 = Router::new().route(&route_path, method_router2); + + let app = Router::new() + .nest(&prefix1, router1) + .nest(&prefix2, router2); + + let full_path1 = format!("{}{}", prefix1, route_path); + let full_path2 = format!("{}{}", prefix2, route_path); + + // Property: Router1's methods should be preserved + for method in &expected_methods1 { + match app.match_route(&full_path1, method) { + RouteMatch::Found { .. } => {} + _ => { + prop_assert!(false, "Method {:?} should be found for {}", method, full_path1); + } + } + } + + // Property: Router2's methods should be preserved + for method in &expected_methods2 { + match app.match_route(&full_path2, method) { + RouteMatch::Found { .. } => {} + _ => { + prop_assert!(false, "Method {:?} should be found for {}", method, full_path2); + } + } + } + + // Property: Methods not registered should return MethodNotAllowed + if !expected_methods1.contains(&Method::DELETE) { + match app.match_route(&full_path1, &Method::DELETE) { + RouteMatch::MethodNotAllowed { allowed } => { + for method in &expected_methods1 { + prop_assert!( + allowed.contains(method), + "Allowed methods for {} should contain {:?}", + full_path1, method + ); + } + } + _ => { + prop_assert!(false, "DELETE should return MethodNotAllowed for {}", full_path1); + } + } + } + } + + /// Property: Three or more routers can be composed without conflicts + /// + /// For any set of three routers nested under different prefixes, + /// all routes should be registered without conflicts. + #[test] + fn prop_three_routers_composition( + prefix1 in "[a-z]{1,3}", + prefix2 in "[a-z]{4,6}", + prefix3 in "[a-z]{7,9}", + num_routes in 1..3usize, + ) { + let prefix1 = format!("/{}", prefix1); + let prefix2 = format!("/{}", prefix2); + let prefix3 = format!("/{}", prefix3); + + async fn handler() -> &'static str { "handler" } + + // Create three routers with same number of routes + let mut router1 = Router::new(); + let mut router2 = Router::new(); + let mut router3 = Router::new(); + + for i in 0..num_routes { + let path = format!("/item{}", i); + router1 = router1.route(&path, get(handler)); + router2 = router2.route(&path, get(handler)); + router3 = router3.route(&path, get(handler)); + } + + // Nest all three routers + let app = Router::new() + .nest(&prefix1, router1) + .nest(&prefix2, router2) + .nest(&prefix3, router3); + + let routes = app.registered_routes(); + + // Property: Total route count should be 3 * num_routes + let expected_count = 3 * num_routes; + prop_assert_eq!( + routes.len(), + expected_count, + "Should have {} routes, got {}", + expected_count, routes.len() + ); + + // Property: All routes should be matchable + for i in 0..num_routes { + let path1 = format!("{}/item{}", prefix1, i); + let path2 = format!("{}/item{}", prefix2, i); + let path3 = format!("{}/item{}", prefix3, i); + + match app.match_route(&path1, &Method::GET) { + RouteMatch::Found { .. } => {} + _ => prop_assert!(false, "Route '{}' should be found", path1), + } + match app.match_route(&path2, &Method::GET) { + RouteMatch::Found { .. } => {} + _ => prop_assert!(false, "Route '{}' should be found", path2), + } + match app.match_route(&path3, &Method::GET) { + RouteMatch::Found { .. } => {} + _ => prop_assert!(false, "Route '{}' should be found", path3), + } + } + } +} +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Nested routes with same path structure but different param names conflict + /// + /// For any existing route with a parameter and a nested route that would create + /// the same path structure with a different parameter name, the router should + /// detect and report the conflict. + #[test] + fn prop_nested_route_conflict_different_param_names( + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 0..2), + param1 in "[a-z][a-z0-9]{1,5}", + param2 in "[a-z][a-z0-9]{1,5}", + ) { + // Ensure param names are different + prop_assume!(param1 != param2); + + async fn handler1() -> &'static str { "handler1" } + async fn handler2() -> &'static str { "handler2" } + + let prefix = format!("/{}", prefix_segments.join("/")); + + // Build the existing route path (with param1) + let existing_path = if route_segments.is_empty() { + format!("{}/{{{}}}", prefix, param1) + } else { + format!("{}/{}/{{{}}}", prefix, route_segments.join("/"), param1) + }; + + // Build the nested route path (with param2) + let nested_path = if route_segments.is_empty() { + format!("/{{{}}}", param2) + } else { + format!("/{}/{{{}}}", route_segments.join("/"), param2) + }; + + // Try to create a conflict + let result = catch_unwind(AssertUnwindSafe(|| { + let parent = Router::new().route(&existing_path, get(handler1)); + let nested = Router::new().route(&nested_path, get(handler2)); + let _app = parent.nest(&prefix, nested); + })); + + // Property: Should detect conflict + prop_assert!( + result.is_err(), + "Nested route '{}{}' should conflict with existing route '{}' but didn't", + prefix, nested_path, existing_path + ); + + // Property: Error message should contain conflict information + if let Err(panic_info) = result { + if let Some(msg) = panic_info.downcast_ref::() { + prop_assert!( + msg.contains("ROUTE CONFLICT DETECTED"), + "Error should contain 'ROUTE CONFLICT DETECTED', got: {}", + msg + ); + prop_assert!( + msg.contains("Existing:") && msg.contains("New:"), + "Error should contain both 'Existing:' and 'New:' labels, got: {}", + msg + ); + } + } + } + + /// Property: Nested routes with exact same path conflict + /// + /// For any existing route and a nested route that would create the exact + /// same path, the router should detect and report the conflict. + #[test] + fn prop_nested_route_conflict_exact_same_path( + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + ) { + async fn handler1() -> &'static str { "handler1" } + async fn handler2() -> &'static str { "handler2" } + + let prefix = format!("/{}", prefix_segments.join("/")); + let route_path = format!("/{}", route_segments.join("/")); + + // Build the full existing path + let existing_path = format!("{}{}", prefix, route_path); + + // Try to create a conflict by nesting a route that creates the same path + let result = catch_unwind(AssertUnwindSafe(|| { + let parent = Router::new().route(&existing_path, get(handler1)); + let nested = Router::new().route(&route_path, get(handler2)); + let _app = parent.nest(&prefix, nested); + })); + + // Property: Should detect conflict + prop_assert!( + result.is_err(), + "Nested route '{}{}' should conflict with existing route '{}' but didn't", + prefix, route_path, existing_path + ); + } + + /// Property: Nested routes under different prefixes don't conflict + /// + /// For any two nested routers with the same internal routes but different + /// prefixes, they should not conflict. + #[test] + fn prop_nested_routes_different_prefixes_no_conflict( + prefix1_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + prefix2_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + route_segments in prop::collection::vec("[a-z][a-z0-9]{1,5}", 1..3), + has_param in any::(), + ) { + // Build prefixes + let prefix1 = format!("/{}", prefix1_segments.join("/")); + let prefix2 = format!("/{}", prefix2_segments.join("/")); + + // Ensure prefixes are different + prop_assume!(prefix1 != prefix2); + + async fn handler1() -> &'static str { "handler1" } + async fn handler2() -> &'static str { "handler2" } + + // Build the route path + let route_path = if has_param { + format!("/{}/{{id}}", route_segments.join("/")) + } else { + format!("/{}", route_segments.join("/")) + }; + + // Try to nest both routers - should NOT conflict + let result = catch_unwind(AssertUnwindSafe(|| { + let nested1 = Router::new().route(&route_path, get(handler1)); + let nested2 = Router::new().route(&route_path, get(handler2)); + + let app = Router::new() + .nest(&prefix1, nested1) + .nest(&prefix2, nested2); + + app.registered_routes().len() + })); + + // Property: Should NOT conflict + prop_assert!( + result.is_ok(), + "Routes under different prefixes '{}' and '{}' should not conflict", + prefix1, prefix2 + ); + + if let Ok(count) = result { + prop_assert_eq!(count, 2, "Should have registered 2 routes"); + } + } + + /// Property: Conflict error message contains resolution guidance + /// + /// When a nested route conflict is detected, the error message should + /// include guidance on how to resolve the conflict. + #[test] + fn prop_nested_conflict_error_contains_guidance( + prefix in "[a-z][a-z0-9]{1,5}", + segment in "[a-z][a-z0-9]{1,5}", + param1 in "[a-z][a-z0-9]{1,5}", + param2 in "[a-z][a-z0-9]{1,5}", + ) { + prop_assume!(param1 != param2); + + async fn handler1() -> &'static str { "handler1" } + async fn handler2() -> &'static str { "handler2" } + + let prefix = format!("/{}", prefix); + let existing_path = format!("{}/{}/{{{}}}", prefix, segment, param1); + let nested_path = format!("/{}/{{{}}}", segment, param2); + + let result = catch_unwind(AssertUnwindSafe(|| { + let parent = Router::new().route(&existing_path, get(handler1)); + let nested = Router::new().route(&nested_path, get(handler2)); + let _app = parent.nest(&prefix, nested); + })); + + prop_assert!(result.is_err(), "Should have detected conflict"); + + if let Err(panic_info) = result { + if let Some(msg) = panic_info.downcast_ref::() { + prop_assert!( + msg.contains("How to resolve:"), + "Error should contain 'How to resolve:' guidance, got: {}", + msg + ); + prop_assert!( + msg.contains("Use different path patterns") || + msg.contains("different path patterns"), + "Error should suggest using different path patterns, got: {}", + msg + ); + } + } + } +}