From 626e515b315eb364c8a1f80011c2ecbb933bdbb9 Mon Sep 17 00:00:00 2001 From: Tuntii Date: Tue, 23 Jun 2026 01:06:46 +0300 Subject: [PATCH 1/2] chore: production readiness P0/P1 improvements - Remove committed .env; add .env.example and .gitignore rules - Split router.rs and app.rs into focused submodules - Add audit workflow, LFS gitattributes, README badges, branch prune script --- .env => .env.example | 6 +- .gitattributes | 7 +- .github/scripts/prune_stale_branches.ps1 | 78 + .github/workflows/audit.yml | 51 + .gitignore | 5 + README.md | 3 + .../src/{app.rs => app/builder.rs} | 1322 +---------- crates/rustapi-core/src/app/config.rs | 110 + crates/rustapi-core/src/app/dispatcher.rs | 55 + crates/rustapi-core/src/app/helpers.rs | 250 ++ crates/rustapi-core/src/app/mod.rs | 16 + crates/rustapi-core/src/app/production.rs | 68 + crates/rustapi-core/src/app/tests.rs | 780 +++++++ crates/rustapi-core/src/app/types.rs | 21 + crates/rustapi-core/src/router/conflict.rs | 73 + crates/rustapi-core/src/router/core.rs | 362 +++ crates/rustapi-core/src/router/match_.rs | 103 + .../rustapi-core/src/router/method_router.rs | 253 +++ crates/rustapi-core/src/router/mod.rs | 18 + .../src/{router.rs => router/tests.rs} | 2006 +++++------------ 20 files changed, 2863 insertions(+), 2724 deletions(-) rename .env => .env.example (67%) create mode 100644 .github/scripts/prune_stale_branches.ps1 create mode 100644 .github/workflows/audit.yml rename crates/rustapi-core/src/{app.rs => app/builder.rs} (55%) create mode 100644 crates/rustapi-core/src/app/config.rs create mode 100644 crates/rustapi-core/src/app/dispatcher.rs create mode 100644 crates/rustapi-core/src/app/helpers.rs create mode 100644 crates/rustapi-core/src/app/mod.rs create mode 100644 crates/rustapi-core/src/app/production.rs create mode 100644 crates/rustapi-core/src/app/tests.rs create mode 100644 crates/rustapi-core/src/app/types.rs create mode 100644 crates/rustapi-core/src/router/conflict.rs create mode 100644 crates/rustapi-core/src/router/core.rs create mode 100644 crates/rustapi-core/src/router/match_.rs create mode 100644 crates/rustapi-core/src/router/method_router.rs create mode 100644 crates/rustapi-core/src/router/mod.rs rename crates/rustapi-core/src/{router.rs => router/tests.rs} (59%) diff --git a/.env b/.env.example similarity index 67% rename from .env rename to .env.example index c06a3232..c0a12faf 100644 --- a/.env +++ b/.env.example @@ -1,8 +1,8 @@ -# Placeholder environment variables for local documentation/examples -# Replace with real values before running database-backed samples. +# Placeholder environment variables for local documentation/examples. +# Copy to .env and replace with real values before running database-backed samples. DATABASE_URL=postgres://postgres:postgres@localhost:5432/rustapi_dev REDIS_URL=redis://127.0.0.1:6379 OAUTH_CLIENT_ID=replace-me OAUTH_CLIENT_SECRET=replace-me OAUTH_REDIRECT_URI=http://127.0.0.1:3000/auth/callback -OIDC_ISSUER_URL=https://accounts.google.com +OIDC_ISSUER_URL=https://accounts.google.com \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index a100e591..857ab3c2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ -# Auto detect text files and perform LF normalization -* text=auto -api/public/*.txt text eol=lf -.github/scripts/*.sh text eol=lf +# Large binary assets — use Git LFS for PNGs over 500 KB +assets/*.png filter=lfs diff=lfs merge=lfs -text +assets/**/*.png filter=lfs diff=lfs merge=lfs -text \ No newline at end of file diff --git a/.github/scripts/prune_stale_branches.ps1 b/.github/scripts/prune_stale_branches.ps1 new file mode 100644 index 00000000..d8b426d4 --- /dev/null +++ b/.github/scripts/prune_stale_branches.ps1 @@ -0,0 +1,78 @@ +# Prune stale local and remote branches for RustAPI. +# Usage: +# .\.github\scripts\prune_stale_branches.ps1 -DryRun +# .\.github\scripts\prune_stale_branches.ps1 +# .\.github\scripts\prune_stale_branches.ps1 -DeleteRemote + +param( + [switch]$DryRun, + [switch]$DeleteRemote, + [int]$StaleDays = 30 +) + +$ErrorActionPreference = "Stop" +$repoRoot = git -C $PSScriptRoot rev-parse --show-toplevel +Set-Location $repoRoot + +$protected = @("main", "master", "gh-pages", "Production-baseline") +$stalePatterns = @("^copilot/", "^copilot-", "^sentinel-", "^secure-") + +function Test-StaleBranchName([string]$Name) { + foreach ($pattern in $stalePatterns) { + if ($Name -match $pattern) { return $true } + } + return $false +} + +Write-Host "Scanning branches older than $StaleDays days..." +$cutoff = (Get-Date).AddDays(-$StaleDays) + +$localBranches = git for-each-ref --format="%(refname:short)|%(committerdate:iso8601)" refs/heads/ | + ForEach-Object { + $parts = $_ -split '\|', 2 + [PSCustomObject]@{ Name = $parts[0]; Date = [datetime]$parts[1] } + } + +$candidates = $localBranches | + Where-Object { $protected -notcontains $_.Name } | + Where-Object { $_.Date -lt $cutoff -or (Test-StaleBranchName $_.Name) } + +if (-not $candidates) { + Write-Host "No stale branches found." + exit 0 +} + +Write-Host "" +Write-Host "Stale branch candidates:" +$candidates | ForEach-Object { Write-Host " $($_.Name) (last commit: $($_.Date.ToString('yyyy-MM-dd')))" } + +if ($DryRun) { + Write-Host "" + Write-Host "Dry run - no branches deleted." + exit 0 +} + +foreach ($branch in $candidates) { + Write-Host "Deleting local branch: $($branch.Name)" + git branch -D $branch.Name +} + +if ($DeleteRemote) { + if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + Write-Error "gh CLI not found. Install GitHub CLI or omit -DeleteRemote." + } + $repo = gh repo view --json nameWithOwner -q .nameWithOwner + $remoteBranches = git for-each-ref --format="%(refname:short)" refs/remotes/origin/ | + ForEach-Object { $_ -replace '^origin/', '' } | + Where-Object { $_ -ne "HEAD" -and $protected -notcontains $_ } | + Where-Object { Test-StaleBranchName $_ } + + foreach ($branch in $remoteBranches) { + Write-Host "Deleting remote branch: origin/$branch" + gh api -X DELETE "repos/$repo/git/refs/heads/$branch" + } +} + +Write-Host "" +Write-Host "Done. Enable GitHub stale branch archiving:" +Write-Host " Repository Settings - Branches - Add branch ruleset (archive after 30 days)" \ No newline at end of file diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 00000000..30f603a3 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,51 @@ +name: Security Audit + +on: + schedule: + # Weekly on Monday at 06:00 UTC + - cron: "0 6 * * 1" + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + cargo-audit: + name: Cargo Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Run cargo audit + run: cargo audit + + public-api-label-gate: + name: Public API Label Gate + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Enforce breaking/feature label on snapshot changes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + GITHUB_EVENT_PATH: ${{ github.event_path }} + run: bash .github/scripts/public_api_label_gate.sh \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3d6bb633..d62a2ed4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ /target /memories + +# Environment files (never commit secrets) +.env +.env.* +!.env.example /scripts # /benches /.kiro diff --git a/README.md b/README.md index 93941c6a..fd089458 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ [![Crates.io](https://img.shields.io/crates/v/rustapi-rs.svg)](https://crates.io/crates/rustapi-rs) [![Docs](https://img.shields.io/badge/docs-cookbook-brightgreen)](docs/cookbook/src/SUMMARY.md) [![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](LICENSE) + [![MSRV](https://img.shields.io/badge/MSRV-1.85-orange)](Cargo.toml) + [![Security Audit](https://github.com/Tuntii/RustAPI/actions/workflows/audit.yml/badge.svg)](https://github.com/Tuntii/RustAPI/actions/workflows/audit.yml) + [![Coverage](https://img.shields.io/badge/coverage-tarpaulin-blue)](https://github.com/Tuntii/RustAPI/actions/workflows/ci.yml) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/Tuntii/RustAPI) diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app/builder.rs similarity index 55% rename from crates/rustapi-core/src/app.rs rename to crates/rustapi-core/src/app/builder.rs index 6523cb21..b223bc89 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app/builder.rs @@ -1,16 +1,21 @@ -//! RustApi application builder - +use super::config::RustApiConfig; +use super::dispatcher::RequestDispatcher; +use super::helpers::{add_path_params_to_operation, normalize_prefix_for_openapi}; +#[cfg(feature = "swagger-ui")] +use super::helpers::{check_basic_auth, unauthorized_response}; +#[cfg(feature = "dashboard")] +use super::helpers::{ + infer_route_feature_gates, is_dashboard_replay_eligible, openapi_tags_for_route, +}; +use super::production::ProductionDefaultsConfig; +use super::types::RustApi; use crate::error::Result; use crate::events::LifecycleHooks; use crate::interceptor::{InterceptorChain, RequestInterceptor, ResponseInterceptor}; -use crate::middleware::{ - BodyLimitLayer, BoxedNext, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT, -}; +use crate::middleware::{BodyLimitLayer, LayerStack, MiddlewareLayer, DEFAULT_BODY_LIMIT}; use crate::response::IntoResponse; use crate::router::{MethodRouter, Router}; use crate::server::Server; -use crate::{Request, Response}; -use http::Extensions; use std::collections::BTreeMap; #[cfg(feature = "dashboard")] use std::collections::BTreeSet; @@ -18,158 +23,6 @@ use std::future::Future; use std::sync::Arc; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; -/// A dispatcher that can drive requests through the RustAPI pipeline -/// (interceptors + layers + router) without any network or serialization overhead. -/// -/// Obtained via [`RustApi::request_dispatcher`]. -#[derive(Clone)] -pub struct RequestDispatcher { - router: Arc, - layers: LayerStack, - interceptors: InterceptorChain, -} - -impl RequestDispatcher { - /// Returns the shared state Extensions from the underlying router. - /// Useful for in-process request construction to preserve `State` etc. - pub fn state_ref(&self) -> Arc { - self.router.state_ref() - } - - /// Dispatch a request through the full stack (interceptors → middleware layers - /// → route handler → response interceptors). - /// - /// This replicates the logic used by the normal HTTP server. - pub async fn dispatch(&self, request: Request) -> Response { - let req = self.interceptors.intercept_request(request); - - let path = req.path().to_owned(); - let method = req.method().clone(); - - let response = if self.layers.is_empty() { - crate::server::route_request_direct(&self.router, req, &path, &method).await - } else { - let router = self.router.clone(); - let p = path.clone(); - let m = method.clone(); - - let routing_handler: BoxedNext = Arc::new(move |r: Request| { - let router = router.clone(); - let pp = p.clone(); - let mm = m.clone(); - Box::pin(async move { crate::server::route_request(&router, r, &pp, &mm).await }) - }); - - self.layers.execute(req, routing_handler).await - }; - - self.interceptors.intercept_response(response) - } -} - -/// Main application builder for RustAPI -/// -/// # Example -/// -/// ```rust,ignore -/// use rustapi_rs::prelude::*; -/// -/// #[tokio::main] -/// async fn main() -> Result<()> { -/// RustApi::new() -/// .state(AppState::new()) -/// .route("/", get(hello)) -/// .route("/users/{id}", get(get_user)) -/// .run("127.0.0.1:8080") -/// .await -/// } -/// ``` -pub struct RustApi { - router: Router, - openapi_spec: rustapi_openapi::OpenApiSpec, - layers: LayerStack, - body_limit: Option, - interceptors: InterceptorChain, - lifecycle_hooks: LifecycleHooks, - hot_reload: bool, - #[cfg(feature = "http3")] - http3_config: Option, - health_check: Option, - health_endpoint_config: Option, - status_config: Option, - #[cfg(feature = "dashboard")] - dashboard_config: Option, -} - -/// Configuration for RustAPI's built-in production baseline preset. -/// -/// This preset bundles together the most common foundation pieces for a -/// production HTTP service: -/// - request IDs on every response -/// - structured tracing spans with service metadata -/// - standard `/health`, `/ready`, and `/live` probes -#[derive(Debug, Clone)] -pub struct ProductionDefaultsConfig { - service_name: String, - version: Option, - tracing_level: tracing::Level, - health_endpoint_config: Option, - enable_request_id: bool, - enable_tracing: bool, - enable_health_endpoints: bool, -} - -impl ProductionDefaultsConfig { - /// Create a new production baseline configuration. - pub fn new(service_name: impl Into) -> Self { - Self { - service_name: service_name.into(), - version: None, - tracing_level: tracing::Level::INFO, - health_endpoint_config: None, - enable_request_id: true, - enable_tracing: true, - enable_health_endpoints: true, - } - } - - /// Annotate tracing spans and default health payloads with an application version. - pub fn version(mut self, version: impl Into) -> Self { - self.version = Some(version.into()); - self - } - - /// Set the tracing log level used by the preset tracing layer. - pub fn tracing_level(mut self, level: tracing::Level) -> Self { - self.tracing_level = level; - self - } - - /// Override the default health endpoint paths. - pub fn health_endpoint_config(mut self, config: crate::health::HealthEndpointConfig) -> Self { - self.health_endpoint_config = Some(config); - self - } - - /// Enable or disable request ID propagation. - pub fn request_id(mut self, enabled: bool) -> Self { - self.enable_request_id = enabled; - self - } - - /// Enable or disable structured tracing middleware. - pub fn tracing(mut self, enabled: bool) -> Self { - self.enable_tracing = enabled; - self - } - - /// Enable or disable built-in health endpoints. - pub fn health_endpoints(mut self, enabled: bool) -> Self { - self.enable_health_endpoints = enabled; - self - } -} - impl RustApi { /// Create a new RustAPI application pub fn new() -> Self { @@ -208,7 +61,7 @@ impl RustApi { /// The primary way to build a RustAPI application. /// /// Collects all routes decorated with `#[rustapi_rs::get]`, `#[rustapi_rs::post]`, etc. - /// at link time via `linkme` and registers them automatically — no manual `.route()` + /// at link time via `linkme` and registers them automatically — no manual `.route()` /// or `.mount_route()` calls needed. This is baked into the core and requires no /// feature flags. /// @@ -449,7 +302,7 @@ impl RustApi { /// ```rust,ignore /// RustApi::new() /// .on_start(|| async { - /// println!("🚀 Server starting..."); + /// println!("🚀 Server starting..."); /// // e.g. run DB migrations, warm caches /// }) /// .run("127.0.0.1:8080") @@ -476,7 +329,7 @@ impl RustApi { /// ```rust,ignore /// RustApi::new() /// .on_shutdown(|| async { - /// println!("👋 Server shutting down..."); + /// println!("👋 Server shutting down..."); /// // e.g. flush logs, close DB connections /// }) /// .run_with_shutdown("127.0.0.1:8080", ctrl_c()) @@ -565,7 +418,7 @@ impl RustApi { } } - fn mount_auto_routes_grouped(mut self) -> Self { + pub(super) fn mount_auto_routes_grouped(mut self) -> Self { let routes = crate::auto_route::collect_auto_routes(); if routes.is_empty() { @@ -1304,10 +1157,10 @@ impl RustApi { .map(|v| v == "1") .unwrap_or(false); - tracing::info!("🔄 Hot-reload mode enabled"); + tracing::info!("🔄 Hot-reload mode enabled"); if is_under_watcher { - tracing::info!(" File watcher active — changes will trigger rebuild + restart"); + tracing::info!(" File watcher active — changes will trigger rebuild + restart"); } else { tracing::info!(" Tip: Run with `cargo rustapi run --watch` for automatic hot-reload"); } @@ -1823,1147 +1676,8 @@ impl RustApi { } } -#[cfg(feature = "dashboard")] -fn openapi_tags_for_route( - spec: &rustapi_openapi::OpenApiSpec, - path: &str, - methods: &[http::Method], -) -> Vec { - let Some(path_item) = spec.paths.get(path) else { - return Vec::new(); - }; - - let mut tags = BTreeSet::new(); - for method in methods { - if let Some(operation) = operation_for_method(path_item, method) { - tags.extend(operation.tags.iter().cloned()); - } - } - - tags.into_iter().collect() -} - -#[cfg(feature = "dashboard")] -fn operation_for_method<'a>( - path_item: &'a rustapi_openapi::PathItem, - method: &http::Method, -) -> Option<&'a rustapi_openapi::Operation> { - match *method { - http::Method::GET => path_item.get.as_ref(), - http::Method::POST => path_item.post.as_ref(), - http::Method::PUT => path_item.put.as_ref(), - http::Method::PATCH => path_item.patch.as_ref(), - http::Method::DELETE => path_item.delete.as_ref(), - http::Method::HEAD => path_item.head.as_ref(), - http::Method::OPTIONS => path_item.options.as_ref(), - http::Method::TRACE => path_item.trace.as_ref(), - _ => None, - } -} - -#[cfg(feature = "dashboard")] -fn infer_route_feature_gates(path: &str) -> Vec { - if path.contains("openapi") || path.contains("docs") { - vec!["core-openapi".to_string()] - } else if path.starts_with("/__rustapi/replays") { - vec!["extras-replay".to_string()] - } else { - Vec::new() - } -} - -#[cfg(feature = "dashboard")] -fn is_dashboard_replay_eligible(path: &str, health_eligible: bool) -> bool { - !health_eligible && !path.starts_with("/__rustapi/") -} - -fn add_path_params_to_operation( - path: &str, - op: &mut rustapi_openapi::Operation, - param_schemas: &BTreeMap, -) { - let mut params: Vec = Vec::new(); - let mut in_brace = false; - let mut current = String::new(); - - for ch in path.chars() { - match ch { - '{' => { - in_brace = true; - current.clear(); - } - '}' => { - if in_brace { - in_brace = false; - if !current.is_empty() { - params.push(current.clone()); - } - } - } - _ => { - if in_brace { - current.push(ch); - } - } - } - } - - if params.is_empty() { - return; - } - - let op_params = &mut op.parameters; - - for name in params { - let already = op_params - .iter() - .any(|p| p.location == "path" && p.name == name); - if already { - continue; - } - - // Use custom schema if provided, otherwise infer from name - let schema = if let Some(schema_type) = param_schemas.get(&name) { - schema_type_to_openapi_schema(schema_type) - } else { - infer_path_param_schema(&name) - }; - - op_params.push(rustapi_openapi::Parameter { - name, - location: "path".to_string(), - required: true, - description: None, - deprecated: None, - schema: Some(schema), - }); - } -} - -/// Convert a schema type string to an OpenAPI schema reference -fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef { - match schema_type.to_lowercase().as_str() { - "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "string", - "format": "uuid" - })), - "integer" | "int" | "int64" | "i64" => { - rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "integer", - "format": "int64" - })) - } - "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "integer", - "format": "int32" - })), - "number" | "float" | "f64" | "f32" => { - rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "number" - })) - } - "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "boolean" - })), - _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "string" - })), - } -} - -/// Infer the OpenAPI schema type for a path parameter based on naming conventions. -/// -/// Common patterns: -/// - `*_id`, `*Id`, `id` → integer (but NOT *uuid) -/// - `*_count`, `*_num`, `page`, `limit`, `offset` → integer -/// - `*_uuid`, `uuid` → string with uuid format -/// - `year`, `month`, `day` → integer -/// - Everything else → string -fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef { - let lower = name.to_lowercase(); - - // UUID patterns (check first to avoid false positive from "id" suffix) - let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid"); - - if is_uuid { - return rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "string", - "format": "uuid" - })); - } - - // Integer patterns - // Integer patterns - let is_integer = lower == "page" - || lower == "limit" - || lower == "offset" - || lower == "count" - || lower.ends_with("_count") - || lower.ends_with("_num") - || lower == "year" - || lower == "month" - || lower == "day" - || lower == "index" - || lower == "position"; - - if is_integer { - rustapi_openapi::SchemaRef::Inline(serde_json::json!({ - "type": "integer", - "format": "int64" - })) - } else { - rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" })) - } -} - -/// Normalize a prefix for OpenAPI paths. -/// -/// Ensures the prefix: -/// - Starts with exactly one leading slash -/// - Has no trailing slash (unless it's just "/") -/// - Has no double slashes -fn normalize_prefix_for_openapi(prefix: &str) -> String { - // Handle empty string - if prefix.is_empty() { - return "/".to_string(); - } - - // Split by slashes and filter out empty segments (handles multiple slashes) - let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect(); - - // If no segments after filtering, return root - if segments.is_empty() { - return "/".to_string(); - } - - // Build the normalized prefix with leading slash - let mut result = String::with_capacity(prefix.len() + 1); - for segment in segments { - result.push('/'); - result.push_str(segment); - } - - result -} - impl Default for RustApi { fn default() -> Self { Self::new() } } - -/// Check Basic Auth header against expected credentials -#[cfg(feature = "swagger-ui")] -fn check_basic_auth(req: &crate::Request, expected: &str) -> bool { - req.headers() - .get(http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .map(|auth| auth == expected) - .unwrap_or(false) -} - -/// Create 401 Unauthorized response with WWW-Authenticate header -#[cfg(feature = "swagger-ui")] -fn unauthorized_response() -> crate::Response { - http::Response::builder() - .status(http::StatusCode::UNAUTHORIZED) - .header( - http::header::WWW_AUTHENTICATE, - "Basic realm=\"API Documentation\"", - ) - .header(http::header::CONTENT_TYPE, "text/plain") - .body(crate::response::Body::from("Unauthorized")) - .unwrap() -} - -/// Configuration builder for RustAPI with auto-routes -pub struct RustApiConfig { - docs_path: Option, - docs_enabled: bool, - api_title: String, - api_version: String, - api_description: Option, - body_limit: Option, - layers: LayerStack, -} - -impl Default for RustApiConfig { - fn default() -> Self { - Self::new() - } -} - -impl RustApiConfig { - pub fn new() -> Self { - Self { - docs_path: Some("/docs".to_string()), - docs_enabled: true, - api_title: "RustAPI".to_string(), - api_version: "1.0.0".to_string(), - api_description: None, - body_limit: None, - layers: LayerStack::new(), - } - } - - /// Set the docs path (default: "/docs") - pub fn docs_path(mut self, path: impl Into) -> Self { - self.docs_path = Some(path.into()); - self - } - - /// Enable or disable docs (default: true) - pub fn docs_enabled(mut self, enabled: bool) -> Self { - self.docs_enabled = enabled; - self - } - - /// Set OpenAPI info - pub fn openapi_info( - mut self, - title: impl Into, - version: impl Into, - description: Option>, - ) -> Self { - self.api_title = title.into(); - self.api_version = version.into(); - self.api_description = description.map(|d| d.into()); - self - } - - /// Set body size limit - pub fn body_limit(mut self, limit: usize) -> Self { - self.body_limit = Some(limit); - self - } - - /// Add a middleware layer - pub fn layer(mut self, layer: L) -> Self - where - L: MiddlewareLayer, - { - self.layers.push(Box::new(layer)); - self - } - - /// Build the RustApi instance - pub fn build(self) -> RustApi { - let mut app = RustApi::new().mount_auto_routes_grouped(); - - // Apply configuration - if let Some(limit) = self.body_limit { - app = app.body_limit(limit); - } - - app = app.openapi_info( - &self.api_title, - &self.api_version, - self.api_description.as_deref(), - ); - - #[cfg(feature = "swagger-ui")] - if self.docs_enabled { - if let Some(path) = self.docs_path { - app = app.docs(&path); - } - } - - // Apply layers - // Note: layers are applied in reverse order in RustApi::layer logic (pushing to vec) - app.layers.extend(self.layers); - - app - } - - /// Build and run the server - pub async fn run( - self, - addr: impl AsRef, - ) -> Result<(), Box> { - self.build().run(addr.as_ref()).await - } -} - -#[cfg(test)] -mod tests { - use super::RustApi; - use crate::extract::{FromRequestParts, State}; - use crate::path_params::PathParams; - use crate::request::Request; - use crate::router::{get, post, Router}; - use bytes::Bytes; - use http::Method; - use proptest::prelude::*; - - #[test] - fn state_is_available_via_extractor() { - let app = RustApi::new().state(123u32); - let router = app.into_router(); - - let req = http::Request::builder() - .method(Method::GET) - .uri("/test") - .body(()) - .unwrap(); - let (parts, _) = req.into_parts(); - - let request = Request::new( - parts, - crate::request::BodyVariant::Buffered(Bytes::new()), - router.state_ref(), - PathParams::new(), - ); - let State(value) = State::::from_request_parts(&request).unwrap(); - assert_eq!(value, 123u32); - } - - #[test] - fn test_path_param_type_inference_integer() { - use super::infer_path_param_schema; - - // Test common integer patterns - let int_params = [ - "page", - "limit", - "offset", - "count", - "item_count", - "year", - "month", - "day", - "index", - "position", - ]; - - for name in int_params { - let schema = infer_path_param_schema(name); - match schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!( - v.get("type").and_then(|v| v.as_str()), - Some("integer"), - "Expected '{}' to be inferred as integer", - name - ); - } - _ => panic!("Expected inline schema for '{}'", name), - } - } - } - - #[test] - fn test_path_param_type_inference_uuid() { - use super::infer_path_param_schema; - - // Test UUID patterns - let uuid_params = ["uuid", "user_uuid", "sessionUuid"]; - - for name in uuid_params { - let schema = infer_path_param_schema(name); - match schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!( - v.get("type").and_then(|v| v.as_str()), - Some("string"), - "Expected '{}' to be inferred as string", - name - ); - assert_eq!( - v.get("format").and_then(|v| v.as_str()), - Some("uuid"), - "Expected '{}' to have uuid format", - name - ); - } - _ => panic!("Expected inline schema for '{}'", name), - } - } - } - - #[test] - fn test_path_param_type_inference_string() { - use super::infer_path_param_schema; - - // Test string (default) patterns - let string_params = [ - "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId", - ]; - - for name in string_params { - let schema = infer_path_param_schema(name); - match schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!( - v.get("type").and_then(|v| v.as_str()), - Some("string"), - "Expected '{}' to be inferred as string", - name - ); - assert!( - v.get("format").is_none() - || v.get("format").and_then(|v| v.as_str()) != Some("uuid"), - "Expected '{}' to NOT have uuid format", - name - ); - } - _ => panic!("Expected inline schema for '{}'", name), - } - } - } - - #[test] - fn test_schema_type_to_openapi_schema() { - use super::schema_type_to_openapi_schema; - - // Test UUID schema - let uuid_schema = schema_type_to_openapi_schema("uuid"); - match uuid_schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string")); - assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid")); - } - _ => panic!("Expected inline schema for uuid"), - } - - // Test integer schemas - for schema_type in ["integer", "int", "int64", "i64"] { - let schema = schema_type_to_openapi_schema(schema_type); - match schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer")); - assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64")); - } - _ => panic!("Expected inline schema for {}", schema_type), - } - } - - // Test int32 schema - let int32_schema = schema_type_to_openapi_schema("int32"); - match int32_schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer")); - assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32")); - } - _ => panic!("Expected inline schema for int32"), - } - - // Test number/float schema - for schema_type in ["number", "float"] { - let schema = schema_type_to_openapi_schema(schema_type); - match schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number")); - } - _ => panic!("Expected inline schema for {}", schema_type), - } - } - - // Test boolean schema - for schema_type in ["boolean", "bool"] { - let schema = schema_type_to_openapi_schema(schema_type); - match schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean")); - } - _ => panic!("Expected inline schema for {}", schema_type), - } - } - - // Test string schema (default) - let string_schema = schema_type_to_openapi_schema("string"); - match string_schema { - rustapi_openapi::SchemaRef::Inline(v) => { - assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string")); - } - _ => panic!("Expected inline schema for string"), - } - } - - // **Feature: router-nesting, Property 11: OpenAPI Integration** - // - // For any nested routes with OpenAPI operations, the operations should appear - // in the parent's OpenAPI spec with prefixed paths and preserved metadata. - // - // **Validates: Requirements 4.1, 4.2** - proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: Nested routes appear in OpenAPI spec with prefixed paths - /// - /// For any router with routes nested under a prefix, all routes should - /// appear in the OpenAPI spec with the prefix prepended to their paths. - #[test] - fn prop_nested_routes_in_openapi_spec( - // 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 through RustApi - let nested_router = Router::new().route(&route_path, get(handler)); - let app = RustApi::new().nest(&prefix, nested_router); - - // Build expected prefixed path for OpenAPI (uses {param} format) - let expected_openapi_path = format!("{}{}", prefix, route_path); - - // Get the OpenAPI spec - let spec = app.openapi_spec(); - - // Property: The prefixed route should exist in OpenAPI paths - prop_assert!( - spec.paths.contains_key(&expected_openapi_path), - "Expected OpenAPI path '{}' not found. Available paths: {:?}", - expected_openapi_path, - spec.paths.keys().collect::>() - ); - - // Property: The path item should have a GET operation - let path_item = spec.paths.get(&expected_openapi_path).unwrap(); - prop_assert!( - path_item.get.is_some(), - "GET operation should exist for path '{}'", - expected_openapi_path - ); - } - - /// Property: Multiple HTTP methods are preserved in OpenAPI spec after nesting - /// - /// For any router with routes having multiple HTTP methods, nesting should - /// preserve all method operations in the OpenAPI spec. - #[test] - fn prop_multiple_methods_preserved_in_openapi( - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), - route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), - ) { - async fn get_handler() -> &'static str { "get" } - async fn post_handler() -> &'static str { "post" } - - // Build the prefix and route path - let prefix = format!("/{}", prefix_segments.join("/")); - let route_path = format!("/{}", route_segments.join("/")); - - // Create nested router with both GET and POST using separate routes - // Since MethodRouter doesn't have chaining methods, we create two routes - let get_route_path = format!("{}/get", route_path); - let post_route_path = format!("{}/post", route_path); - let nested_router = Router::new() - .route(&get_route_path, get(get_handler)) - .route(&post_route_path, post(post_handler)); - let app = RustApi::new().nest(&prefix, nested_router); - - // Build expected prefixed paths for OpenAPI - let expected_get_path = format!("{}{}", prefix, get_route_path); - let expected_post_path = format!("{}{}", prefix, post_route_path); - - // Get the OpenAPI spec - let spec = app.openapi_spec(); - - // Property: Both paths should exist - prop_assert!( - spec.paths.contains_key(&expected_get_path), - "Expected OpenAPI path '{}' not found", - expected_get_path - ); - prop_assert!( - spec.paths.contains_key(&expected_post_path), - "Expected OpenAPI path '{}' not found", - expected_post_path - ); - - // Property: GET operation should exist on get path - let get_path_item = spec.paths.get(&expected_get_path).unwrap(); - prop_assert!( - get_path_item.get.is_some(), - "GET operation should exist for path '{}'", - expected_get_path - ); - - // Property: POST operation should exist on post path - let post_path_item = spec.paths.get(&expected_post_path).unwrap(); - prop_assert!( - post_path_item.post.is_some(), - "POST operation should exist for path '{}'", - expected_post_path - ); - } - - /// Property: Path parameters are added to OpenAPI operations after nesting - /// - /// For any nested route with path parameters, the OpenAPI operation should - /// include the path parameters. - #[test] - fn prop_path_params_in_openapi_after_nesting( - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), - param_name in "[a-z][a-z0-9]{0,5}", - ) { - async fn handler() -> &'static str { "handler" } - - // Build the prefix and route path with parameter - let prefix = format!("/{}", prefix_segments.join("/")); - let route_path = format!("/{{{}}}", param_name); - - // Create nested router - let nested_router = Router::new().route(&route_path, get(handler)); - let app = RustApi::new().nest(&prefix, nested_router); - - // Build expected prefixed path for OpenAPI - let expected_openapi_path = format!("{}{}", prefix, route_path); - - // Get the OpenAPI spec - let spec = app.openapi_spec(); - - // Property: The path should exist - prop_assert!( - spec.paths.contains_key(&expected_openapi_path), - "Expected OpenAPI path '{}' not found", - expected_openapi_path - ); - - // Property: The GET operation should have the path parameter - let path_item = spec.paths.get(&expected_openapi_path).unwrap(); - let get_op = path_item.get.as_ref().unwrap(); - - prop_assert!( - !get_op.parameters.is_empty(), - "Operation should have parameters for path '{}'", - expected_openapi_path - ); - - let params = &get_op.parameters; - let has_param = params.iter().any(|p| p.name == param_name && p.location == "path"); - prop_assert!( - has_param, - "Path parameter '{}' should exist in operation parameters. Found: {:?}", - param_name, - params.iter().map(|p| &p.name).collect::>() - ); - } - } - - // **Feature: router-nesting, Property 13: RustApi Integration** - // - // For any router nested through `RustApi::new().nest()`, the behavior should be - // identical to nesting through `Router::new().nest()`, and routes should appear - // in the OpenAPI spec. - // - // **Validates: Requirements 6.1, 6.2** - proptest! { - #![proptest_config(ProptestConfig::with_cases(100))] - - /// Property: RustApi::nest delegates to Router::nest and produces identical route registration - /// - /// For any router with routes nested under a prefix, nesting through RustApi - /// should produce the same route registration as nesting through Router directly. - #[test] - fn prop_rustapi_nest_delegates_to_router_nest( - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), - 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 - let nested_router_for_rustapi = Router::new().route(&route_path, get(handler)); - let nested_router_for_router = Router::new().route(&route_path, get(handler)); - - // Nest through RustApi - let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi); - let rustapi_router = rustapi_app.into_router(); - - // Nest through Router directly - let router_app = Router::new().nest(&prefix, nested_router_for_router); - - // Property: Both should have the same registered routes - let rustapi_routes = rustapi_router.registered_routes(); - let router_routes = router_app.registered_routes(); - - prop_assert_eq!( - rustapi_routes.len(), - router_routes.len(), - "RustApi and Router should have same number of routes" - ); - - // Property: All routes from Router should exist in RustApi - for (path, info) in router_routes { - prop_assert!( - rustapi_routes.contains_key(path), - "Route '{}' from Router should exist in RustApi routes", - path - ); - - let rustapi_info = rustapi_routes.get(path).unwrap(); - prop_assert_eq!( - &info.path, &rustapi_info.path, - "Display paths should match for route '{}'", - path - ); - prop_assert_eq!( - info.methods.len(), rustapi_info.methods.len(), - "Method count should match for route '{}'", - path - ); - } - } - - /// Property: RustApi::nest includes nested routes in OpenAPI spec - /// - /// For any router with routes nested through RustApi, all routes should - /// appear in the OpenAPI specification with prefixed paths. - #[test] - fn prop_rustapi_nest_includes_routes_in_openapi( - prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), - 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 through RustApi - let nested_router = Router::new().route(&route_path, get(handler)); - let app = RustApi::new().nest(&prefix, nested_router); - - // Build expected prefixed path for OpenAPI - let expected_openapi_path = format!("{}{}", prefix, route_path); - - // Get the OpenAPI spec - let spec = app.openapi_spec(); - - // Property: The prefixed route should exist in OpenAPI paths - prop_assert!( - spec.paths.contains_key(&expected_openapi_path), - "Expected OpenAPI path '{}' not found. Available paths: {:?}", - expected_openapi_path, - spec.paths.keys().collect::>() - ); - - // Property: The path item should have a GET operation - let path_item = spec.paths.get(&expected_openapi_path).unwrap(); - prop_assert!( - path_item.get.is_some(), - "GET operation should exist for path '{}'", - expected_openapi_path - ); - } - - /// Property: RustApi::nest route matching is identical to Router::nest - /// - /// For any nested route, matching through RustApi should produce the same - /// result as matching through Router directly. - #[test] - fn prop_rustapi_nest_route_matching_identical( - 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), - param_value in "[a-z0-9]{1,10}", - ) { - use crate::router::RouteMatch; - - async fn handler() -> &'static str { "handler" } - - // Build the prefix and route path with parameter - let prefix = format!("/{}", prefix_segments.join("/")); - let route_path = format!("/{}/{{id}}", route_segments.join("/")); - - // Create nested routers - let nested_router_for_rustapi = Router::new().route(&route_path, get(handler)); - let nested_router_for_router = Router::new().route(&route_path, get(handler)); - - // Nest through both RustApi and Router - let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi); - let rustapi_router = rustapi_app.into_router(); - let router_app = Router::new().nest(&prefix, nested_router_for_router); - - // Build the full path to match - let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value); - - // Match through both - let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET); - let router_match = router_app.match_route(&full_path, &Method::GET); - - // Property: Both should return Found with same parameters - match (rustapi_match, router_match) { - (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => { - prop_assert_eq!( - rustapi_params.len(), - router_params.len(), - "Parameter count should match" - ); - for (key, value) in &router_params { - prop_assert!( - rustapi_params.contains_key(key), - "RustApi should have parameter '{}'", - key - ); - prop_assert_eq!( - rustapi_params.get(key).unwrap(), - value, - "Parameter '{}' value should match", - key - ); - } - } - (rustapi_result, router_result) => { - prop_assert!( - false, - "Both should return Found, but RustApi returned {:?} and Router returned {:?}", - match rustapi_result { - RouteMatch::Found { .. } => "Found", - RouteMatch::NotFound => "NotFound", - RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed", - }, - match router_result { - RouteMatch::Found { .. } => "Found", - RouteMatch::NotFound => "NotFound", - RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed", - } - ); - } - } - } - } - - /// Unit test: Verify OpenAPI operations are propagated during nesting - #[test] - fn test_openapi_operations_propagated_during_nesting() { - async fn list_users() -> &'static str { - "list users" - } - async fn get_user() -> &'static str { - "get user" - } - async fn create_user() -> &'static str { - "create user" - } - - // Create nested router with multiple routes - // Note: We use separate routes since MethodRouter doesn't support chaining - let users_router = Router::new() - .route("/", get(list_users)) - .route("/create", post(create_user)) - .route("/{id}", get(get_user)); - - // Nest under /api/v1/users - let app = RustApi::new().nest("/api/v1/users", users_router); - - let spec = app.openapi_spec(); - - // Verify /api/v1/users path exists with GET - assert!( - spec.paths.contains_key("/api/v1/users"), - "Should have /api/v1/users path" - ); - let users_path = spec.paths.get("/api/v1/users").unwrap(); - assert!(users_path.get.is_some(), "Should have GET operation"); - - // Verify /api/v1/users/create path exists with POST - assert!( - spec.paths.contains_key("/api/v1/users/create"), - "Should have /api/v1/users/create path" - ); - let create_path = spec.paths.get("/api/v1/users/create").unwrap(); - assert!(create_path.post.is_some(), "Should have POST operation"); - - // Verify /api/v1/users/{id} path exists with GET - assert!( - spec.paths.contains_key("/api/v1/users/{id}"), - "Should have /api/v1/users/{{id}} path" - ); - let user_path = spec.paths.get("/api/v1/users/{id}").unwrap(); - assert!( - user_path.get.is_some(), - "Should have GET operation for user by id" - ); - - // Verify path parameter is added - let get_user_op = user_path.get.as_ref().unwrap(); - assert!(!get_user_op.parameters.is_empty(), "Should have parameters"); - let params = &get_user_op.parameters; - assert!( - params - .iter() - .any(|p| p.name == "id" && p.location == "path"), - "Should have 'id' path parameter" - ); - } - - /// Unit test: Verify nested routes don't appear without nesting - #[test] - fn test_openapi_spec_empty_without_routes() { - let app = RustApi::new(); - let spec = app.openapi_spec(); - - // Should have no paths (except potentially default ones) - assert!( - spec.paths.is_empty(), - "OpenAPI spec should have no paths without routes" - ); - } - - /// Unit test: Verify RustApi::nest delegates correctly to Router::nest - /// - /// **Feature: router-nesting, Property 13: RustApi Integration** - /// **Validates: Requirements 6.1, 6.2** - #[test] - fn test_rustapi_nest_delegates_to_router_nest() { - use crate::router::RouteMatch; - - async fn list_users() -> &'static str { - "list users" - } - async fn get_user() -> &'static str { - "get user" - } - async fn create_user() -> &'static str { - "create user" - } - - // Create nested router with multiple routes - let users_router = Router::new() - .route("/", get(list_users)) - .route("/create", post(create_user)) - .route("/{id}", get(get_user)); - - // Nest through RustApi - let app = RustApi::new().nest("/api/v1/users", users_router); - let router = app.into_router(); - - // Verify routes are registered correctly - let routes = router.registered_routes(); - assert_eq!(routes.len(), 3, "Should have 3 routes registered"); - - // Verify route paths - assert!( - routes.contains_key("/api/v1/users"), - "Should have /api/v1/users route" - ); - assert!( - routes.contains_key("/api/v1/users/create"), - "Should have /api/v1/users/create route" - ); - assert!( - routes.contains_key("/api/v1/users/:id"), - "Should have /api/v1/users/:id route" - ); - - // Verify route matching works - match router.match_route("/api/v1/users", &Method::GET) { - RouteMatch::Found { params, .. } => { - assert!(params.is_empty(), "Root route should have no params"); - } - _ => panic!("GET /api/v1/users should be found"), - } - - match router.match_route("/api/v1/users/create", &Method::POST) { - RouteMatch::Found { params, .. } => { - assert!(params.is_empty(), "Create route should have no params"); - } - _ => panic!("POST /api/v1/users/create should be found"), - } - - match router.match_route("/api/v1/users/123", &Method::GET) { - RouteMatch::Found { params, .. } => { - assert_eq!( - params.get("id"), - Some(&"123".to_string()), - "Should extract id param" - ); - } - _ => panic!("GET /api/v1/users/123 should be found"), - } - - // Verify method not allowed - match router.match_route("/api/v1/users", &Method::DELETE) { - RouteMatch::MethodNotAllowed { allowed } => { - assert!(allowed.contains(&Method::GET), "Should allow GET"); - } - _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"), - } - } - - /// Unit test: Verify RustApi::nest includes routes in OpenAPI spec - /// - /// **Feature: router-nesting, Property 13: RustApi Integration** - /// **Validates: Requirements 6.1, 6.2** - #[test] - fn test_rustapi_nest_includes_routes_in_openapi_spec() { - async fn list_items() -> &'static str { - "list items" - } - async fn get_item() -> &'static str { - "get item" - } - - // Create nested router - let items_router = Router::new() - .route("/", get(list_items)) - .route("/{item_id}", get(get_item)); - - // Nest through RustApi - let app = RustApi::new().nest("/api/items", items_router); - - // Verify OpenAPI spec - let spec = app.openapi_spec(); - - // Verify paths exist - assert!( - spec.paths.contains_key("/api/items"), - "Should have /api/items in OpenAPI" - ); - assert!( - spec.paths.contains_key("/api/items/{item_id}"), - "Should have /api/items/{{item_id}} in OpenAPI" - ); - - // Verify operations - let list_path = spec.paths.get("/api/items").unwrap(); - assert!( - list_path.get.is_some(), - "Should have GET operation for /api/items" - ); - - let get_path = spec.paths.get("/api/items/{item_id}").unwrap(); - assert!( - get_path.get.is_some(), - "Should have GET operation for /api/items/{{item_id}}" - ); - - // Verify path parameter is added - let get_op = get_path.get.as_ref().unwrap(); - assert!(!get_op.parameters.is_empty(), "Should have parameters"); - let params = &get_op.parameters; - assert!( - params - .iter() - .any(|p| p.name == "item_id" && p.location == "path"), - "Should have 'item_id' path parameter" - ); - } -} diff --git a/crates/rustapi-core/src/app/config.rs b/crates/rustapi-core/src/app/config.rs new file mode 100644 index 00000000..3e69cef0 --- /dev/null +++ b/crates/rustapi-core/src/app/config.rs @@ -0,0 +1,110 @@ +use super::types::RustApi; +use crate::middleware::{LayerStack, MiddlewareLayer}; + +/// Configuration builder for RustAPI with auto-routes +pub struct RustApiConfig { + docs_path: Option, + docs_enabled: bool, + api_title: String, + api_version: String, + api_description: Option, + body_limit: Option, + layers: LayerStack, +} + +impl Default for RustApiConfig { + fn default() -> Self { + Self::new() + } +} + +impl RustApiConfig { + pub fn new() -> Self { + Self { + docs_path: Some("/docs".to_string()), + docs_enabled: true, + api_title: "RustAPI".to_string(), + api_version: "1.0.0".to_string(), + api_description: None, + body_limit: None, + layers: LayerStack::new(), + } + } + + /// Set the docs path (default: "/docs") + pub fn docs_path(mut self, path: impl Into) -> Self { + self.docs_path = Some(path.into()); + self + } + + /// Enable or disable docs (default: true) + pub fn docs_enabled(mut self, enabled: bool) -> Self { + self.docs_enabled = enabled; + self + } + + /// Set OpenAPI info + pub fn openapi_info( + mut self, + title: impl Into, + version: impl Into, + description: Option>, + ) -> Self { + self.api_title = title.into(); + self.api_version = version.into(); + self.api_description = description.map(|d| d.into()); + self + } + + /// Set body size limit + pub fn body_limit(mut self, limit: usize) -> Self { + self.body_limit = Some(limit); + self + } + + /// Add a middleware layer + pub fn layer(mut self, layer: L) -> Self + where + L: MiddlewareLayer, + { + self.layers.push(Box::new(layer)); + self + } + + /// Build the RustApi instance + pub fn build(self) -> RustApi { + let mut app = RustApi::new().mount_auto_routes_grouped(); + + // Apply configuration + if let Some(limit) = self.body_limit { + app = app.body_limit(limit); + } + + app = app.openapi_info( + &self.api_title, + &self.api_version, + self.api_description.as_deref(), + ); + + #[cfg(feature = "swagger-ui")] + if self.docs_enabled { + if let Some(path) = self.docs_path { + app = app.docs(&path); + } + } + + // Apply layers + // Note: layers are applied in reverse order in RustApi::layer logic (pushing to vec) + app.layers.extend(self.layers); + + app + } + + /// Build and run the server + pub async fn run( + self, + addr: impl AsRef, + ) -> Result<(), Box> { + self.build().run(addr.as_ref()).await + } +} diff --git a/crates/rustapi-core/src/app/dispatcher.rs b/crates/rustapi-core/src/app/dispatcher.rs new file mode 100644 index 00000000..0bb6d9f4 --- /dev/null +++ b/crates/rustapi-core/src/app/dispatcher.rs @@ -0,0 +1,55 @@ +use crate::interceptor::InterceptorChain; +use crate::middleware::{BoxedNext, LayerStack}; +use crate::router::Router; +use crate::{Request, Response}; +use http::Extensions; +use std::sync::Arc; + +/// A dispatcher that can drive requests through the RustAPI pipeline +/// (interceptors + layers + router) without any network or serialization overhead. +/// +/// Obtained via [`RustApi::request_dispatcher`]. +#[derive(Clone)] +pub struct RequestDispatcher { + pub(super) router: Arc, + pub(super) layers: LayerStack, + pub(super) interceptors: InterceptorChain, +} + +impl RequestDispatcher { + /// Returns the shared state Extensions from the underlying router. + /// Useful for in-process request construction to preserve `State` etc. + pub fn state_ref(&self) -> Arc { + self.router.state_ref() + } + + /// Dispatch a request through the full stack (interceptors, middleware layers, + /// route handler, and response interceptors). + /// + /// This replicates the logic used by the normal HTTP server. + pub async fn dispatch(&self, request: Request) -> Response { + let req = self.interceptors.intercept_request(request); + + let path = req.path().to_owned(); + let method = req.method().clone(); + + let response = if self.layers.is_empty() { + crate::server::route_request_direct(&self.router, req, &path, &method).await + } else { + let router = self.router.clone(); + let p = path.clone(); + let m = method.clone(); + + let routing_handler: BoxedNext = Arc::new(move |r: Request| { + let router = router.clone(); + let pp = p.clone(); + let mm = m.clone(); + Box::pin(async move { crate::server::route_request(&router, r, &pp, &mm).await }) + }); + + self.layers.execute(req, routing_handler).await + }; + + self.interceptors.intercept_response(response) + } +} diff --git a/crates/rustapi-core/src/app/helpers.rs b/crates/rustapi-core/src/app/helpers.rs new file mode 100644 index 00000000..67d815e1 --- /dev/null +++ b/crates/rustapi-core/src/app/helpers.rs @@ -0,0 +1,250 @@ +use std::collections::BTreeMap; +#[cfg(feature = "dashboard")] +use std::collections::BTreeSet; + +#[cfg(feature = "dashboard")] +pub(super) fn openapi_tags_for_route( + spec: &rustapi_openapi::OpenApiSpec, + path: &str, + methods: &[http::Method], +) -> Vec { + let Some(path_item) = spec.paths.get(path) else { + return Vec::new(); + }; + + let mut tags = BTreeSet::new(); + for method in methods { + if let Some(operation) = operation_for_method(path_item, method) { + tags.extend(operation.tags.iter().cloned()); + } + } + + tags.into_iter().collect() +} + +#[cfg(feature = "dashboard")] +pub(super) fn operation_for_method<'a>( + path_item: &'a rustapi_openapi::PathItem, + method: &http::Method, +) -> Option<&'a rustapi_openapi::Operation> { + match *method { + http::Method::GET => path_item.get.as_ref(), + http::Method::POST => path_item.post.as_ref(), + http::Method::PUT => path_item.put.as_ref(), + http::Method::PATCH => path_item.patch.as_ref(), + http::Method::DELETE => path_item.delete.as_ref(), + http::Method::HEAD => path_item.head.as_ref(), + http::Method::OPTIONS => path_item.options.as_ref(), + http::Method::TRACE => path_item.trace.as_ref(), + _ => None, + } +} + +#[cfg(feature = "dashboard")] +pub(super) fn infer_route_feature_gates(path: &str) -> Vec { + if path.contains("openapi") || path.contains("docs") { + vec!["core-openapi".to_string()] + } else if path.starts_with("/__rustapi/replays") { + vec!["extras-replay".to_string()] + } else { + Vec::new() + } +} + +#[cfg(feature = "dashboard")] +pub(super) fn is_dashboard_replay_eligible(path: &str, health_eligible: bool) -> bool { + !health_eligible && !path.starts_with("/__rustapi/") +} + +pub(super) fn add_path_params_to_operation( + path: &str, + op: &mut rustapi_openapi::Operation, + param_schemas: &BTreeMap, +) { + let mut params: Vec = Vec::new(); + let mut in_brace = false; + let mut current = String::new(); + + for ch in path.chars() { + match ch { + '{' => { + in_brace = true; + current.clear(); + } + '}' => { + if in_brace { + in_brace = false; + if !current.is_empty() { + params.push(current.clone()); + } + } + } + _ => { + if in_brace { + current.push(ch); + } + } + } + } + + if params.is_empty() { + return; + } + + let op_params = &mut op.parameters; + + for name in params { + let already = op_params + .iter() + .any(|p| p.location == "path" && p.name == name); + if already { + continue; + } + + // Use custom schema if provided, otherwise infer from name + let schema = if let Some(schema_type) = param_schemas.get(&name) { + schema_type_to_openapi_schema(schema_type) + } else { + infer_path_param_schema(&name) + }; + + op_params.push(rustapi_openapi::Parameter { + name, + location: "path".to_string(), + required: true, + description: None, + deprecated: None, + schema: Some(schema), + }); + } +} + +/// Convert a schema type string to an OpenAPI schema reference +pub(super) fn schema_type_to_openapi_schema(schema_type: &str) -> rustapi_openapi::SchemaRef { + match schema_type.to_lowercase().as_str() { + "uuid" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ + "type": "string", + "format": "uuid" + })), + "integer" | "int" | "int64" | "i64" => { + rustapi_openapi::SchemaRef::Inline(serde_json::json!({ + "type": "integer", + "format": "int64" + })) + } + "int32" | "i32" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ + "type": "integer", + "format": "int32" + })), + "number" | "float" | "f64" | "f32" => { + rustapi_openapi::SchemaRef::Inline(serde_json::json!({ + "type": "number" + })) + } + "boolean" | "bool" => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ + "type": "boolean" + })), + _ => rustapi_openapi::SchemaRef::Inline(serde_json::json!({ + "type": "string" + })), + } +} + +/// Infer the OpenAPI schema type for a path parameter based on naming conventions. +/// +/// Common patterns: +/// - `*_id`, `*Id`, `id` → integer (but NOT *uuid) +/// - `*_count`, `*_num`, `page`, `limit`, `offset` → integer +/// - `*_uuid`, `uuid` → string with uuid format +/// - `year`, `month`, `day` → integer +/// - Everything else → string +pub(super) fn infer_path_param_schema(name: &str) -> rustapi_openapi::SchemaRef { + let lower = name.to_lowercase(); + + // UUID patterns (check first to avoid false positive from "id" suffix) + let is_uuid = lower == "uuid" || lower.ends_with("_uuid") || lower.ends_with("uuid"); + + if is_uuid { + return rustapi_openapi::SchemaRef::Inline(serde_json::json!({ + "type": "string", + "format": "uuid" + })); + } + + // Integer patterns + // Integer patterns + let is_integer = lower == "page" + || lower == "limit" + || lower == "offset" + || lower == "count" + || lower.ends_with("_count") + || lower.ends_with("_num") + || lower == "year" + || lower == "month" + || lower == "day" + || lower == "index" + || lower == "position"; + + if is_integer { + rustapi_openapi::SchemaRef::Inline(serde_json::json!({ + "type": "integer", + "format": "int64" + })) + } else { + rustapi_openapi::SchemaRef::Inline(serde_json::json!({ "type": "string" })) + } +} + +/// Normalize a prefix for OpenAPI paths. +/// +/// Ensures the prefix: +/// - Starts with exactly one leading slash +/// - Has no trailing slash (unless it's just "/") +/// - Has no double slashes +pub(super) fn normalize_prefix_for_openapi(prefix: &str) -> String { + // Handle empty string + if prefix.is_empty() { + return "/".to_string(); + } + + // Split by slashes and filter out empty segments (handles multiple slashes) + let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect(); + + // If no segments after filtering, return root + if segments.is_empty() { + return "/".to_string(); + } + + // Build the normalized prefix with leading slash + let mut result = String::with_capacity(prefix.len() + 1); + for segment in segments { + result.push('/'); + result.push_str(segment); + } + + result +} + +/// Check Basic Auth header against expected credentials +#[cfg(feature = "swagger-ui")] +pub(super) fn check_basic_auth(req: &crate::Request, expected: &str) -> bool { + req.headers() + .get(http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .map(|auth| auth == expected) + .unwrap_or(false) +} + +/// Create 401 Unauthorized response with WWW-Authenticate header +#[cfg(feature = "swagger-ui")] +pub(super) fn unauthorized_response() -> crate::Response { + http::Response::builder() + .status(http::StatusCode::UNAUTHORIZED) + .header( + http::header::WWW_AUTHENTICATE, + "Basic realm=\"API Documentation\"", + ) + .header(http::header::CONTENT_TYPE, "text/plain") + .body(crate::response::Body::from("Unauthorized")) + .unwrap() +} diff --git a/crates/rustapi-core/src/app/mod.rs b/crates/rustapi-core/src/app/mod.rs new file mode 100644 index 00000000..52d9e168 --- /dev/null +++ b/crates/rustapi-core/src/app/mod.rs @@ -0,0 +1,16 @@ +//! RustApi application builder + +mod builder; +mod config; +mod dispatcher; +mod helpers; +mod production; +mod types; + +#[cfg(test)] +mod tests; + +pub use config::RustApiConfig; +pub use dispatcher::RequestDispatcher; +pub use production::ProductionDefaultsConfig; +pub use types::RustApi; diff --git a/crates/rustapi-core/src/app/production.rs b/crates/rustapi-core/src/app/production.rs new file mode 100644 index 00000000..c815167a --- /dev/null +++ b/crates/rustapi-core/src/app/production.rs @@ -0,0 +1,68 @@ +/// Configuration for RustAPI's built-in production baseline preset. +/// +/// This preset bundles together the most common foundation pieces for a +/// production HTTP service: +/// - request IDs on every response +/// - structured tracing spans with service metadata +/// - standard `/health`, `/ready`, and `/live` probes +#[derive(Debug, Clone)] +pub struct ProductionDefaultsConfig { + pub(super) service_name: String, + pub(super) version: Option, + pub(super) tracing_level: tracing::Level, + pub(super) health_endpoint_config: Option, + pub(super) enable_request_id: bool, + pub(super) enable_tracing: bool, + pub(super) enable_health_endpoints: bool, +} + +impl ProductionDefaultsConfig { + /// Create a new production baseline configuration. + pub fn new(service_name: impl Into) -> Self { + Self { + service_name: service_name.into(), + version: None, + tracing_level: tracing::Level::INFO, + health_endpoint_config: None, + enable_request_id: true, + enable_tracing: true, + enable_health_endpoints: true, + } + } + + /// Annotate tracing spans and default health payloads with an application version. + pub fn version(mut self, version: impl Into) -> Self { + self.version = Some(version.into()); + self + } + + /// Set the tracing log level used by the preset tracing layer. + pub fn tracing_level(mut self, level: tracing::Level) -> Self { + self.tracing_level = level; + self + } + + /// Override the default health endpoint paths. + pub fn health_endpoint_config(mut self, config: crate::health::HealthEndpointConfig) -> Self { + self.health_endpoint_config = Some(config); + self + } + + /// Enable or disable request ID propagation. + pub fn request_id(mut self, enabled: bool) -> Self { + self.enable_request_id = enabled; + self + } + + /// Enable or disable structured tracing middleware. + pub fn tracing(mut self, enabled: bool) -> Self { + self.enable_tracing = enabled; + self + } + + /// Enable or disable built-in health endpoints. + pub fn health_endpoints(mut self, enabled: bool) -> Self { + self.enable_health_endpoints = enabled; + self + } +} diff --git a/crates/rustapi-core/src/app/tests.rs b/crates/rustapi-core/src/app/tests.rs new file mode 100644 index 00000000..41eff022 --- /dev/null +++ b/crates/rustapi-core/src/app/tests.rs @@ -0,0 +1,780 @@ +use super::RustApi; +use crate::extract::{FromRequestParts, State}; +use crate::path_params::PathParams; +use crate::request::Request; +use crate::router::{get, post, Router}; +use bytes::Bytes; +use http::Method; +use proptest::prelude::*; + +#[test] +fn state_is_available_via_extractor() { + let app = RustApi::new().state(123u32); + let router = app.into_router(); + + let req = http::Request::builder() + .method(Method::GET) + .uri("/test") + .body(()) + .unwrap(); + let (parts, _) = req.into_parts(); + + let request = Request::new( + parts, + crate::request::BodyVariant::Buffered(Bytes::new()), + router.state_ref(), + PathParams::new(), + ); + let State(value) = State::::from_request_parts(&request).unwrap(); + assert_eq!(value, 123u32); +} + +#[test] +fn test_path_param_type_inference_integer() { + use super::helpers::infer_path_param_schema; + + // Test common integer patterns + let int_params = [ + "page", + "limit", + "offset", + "count", + "item_count", + "year", + "month", + "day", + "index", + "position", + ]; + + for name in int_params { + let schema = infer_path_param_schema(name); + match schema { + rustapi_openapi::SchemaRef::Inline(v) => { + assert_eq!( + v.get("type").and_then(|v| v.as_str()), + Some("integer"), + "Expected '{}' to be inferred as integer", + name + ); + } + _ => panic!("Expected inline schema for '{}'", name), + } + } +} + +#[test] +fn test_path_param_type_inference_uuid() { + use super::helpers::infer_path_param_schema; + + // Test UUID patterns + let uuid_params = ["uuid", "user_uuid", "sessionUuid"]; + + for name in uuid_params { + let schema = infer_path_param_schema(name); + match schema { + rustapi_openapi::SchemaRef::Inline(v) => { + assert_eq!( + v.get("type").and_then(|v| v.as_str()), + Some("string"), + "Expected '{}' to be inferred as string", + name + ); + assert_eq!( + v.get("format").and_then(|v| v.as_str()), + Some("uuid"), + "Expected '{}' to have uuid format", + name + ); + } + _ => panic!("Expected inline schema for '{}'", name), + } + } +} + +#[test] +fn test_path_param_type_inference_string() { + use super::helpers::infer_path_param_schema; + + // Test string (default) patterns + let string_params = [ + "name", "slug", "code", "token", "username", "id", "user_id", "userId", "postId", + ]; + + for name in string_params { + let schema = infer_path_param_schema(name); + match schema { + rustapi_openapi::SchemaRef::Inline(v) => { + assert_eq!( + v.get("type").and_then(|v| v.as_str()), + Some("string"), + "Expected '{}' to be inferred as string", + name + ); + assert!( + v.get("format").is_none() + || v.get("format").and_then(|v| v.as_str()) != Some("uuid"), + "Expected '{}' to NOT have uuid format", + name + ); + } + _ => panic!("Expected inline schema for '{}'", name), + } + } +} + +#[test] +fn test_schema_type_to_openapi_schema() { + use super::helpers::schema_type_to_openapi_schema; + + // Test UUID schema + let uuid_schema = schema_type_to_openapi_schema("uuid"); + match uuid_schema { + rustapi_openapi::SchemaRef::Inline(v) => { + assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string")); + assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("uuid")); + } + _ => panic!("Expected inline schema for uuid"), + } + + // Test integer schemas + for schema_type in ["integer", "int", "int64", "i64"] { + let schema = schema_type_to_openapi_schema(schema_type); + match schema { + rustapi_openapi::SchemaRef::Inline(v) => { + assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer")); + assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int64")); + } + _ => panic!("Expected inline schema for {}", schema_type), + } + } + + // Test int32 schema + let int32_schema = schema_type_to_openapi_schema("int32"); + match int32_schema { + rustapi_openapi::SchemaRef::Inline(v) => { + assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("integer")); + assert_eq!(v.get("format").and_then(|v| v.as_str()), Some("int32")); + } + _ => panic!("Expected inline schema for int32"), + } + + // Test number/float schema + for schema_type in ["number", "float"] { + let schema = schema_type_to_openapi_schema(schema_type); + match schema { + rustapi_openapi::SchemaRef::Inline(v) => { + assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("number")); + } + _ => panic!("Expected inline schema for {}", schema_type), + } + } + + // Test boolean schema + for schema_type in ["boolean", "bool"] { + let schema = schema_type_to_openapi_schema(schema_type); + match schema { + rustapi_openapi::SchemaRef::Inline(v) => { + assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("boolean")); + } + _ => panic!("Expected inline schema for {}", schema_type), + } + } + + // Test string schema (default) + let string_schema = schema_type_to_openapi_schema("string"); + match string_schema { + rustapi_openapi::SchemaRef::Inline(v) => { + assert_eq!(v.get("type").and_then(|v| v.as_str()), Some("string")); + } + _ => panic!("Expected inline schema for string"), + } +} + +// **Feature: router-nesting, Property 11: OpenAPI Integration** +// +// For any nested routes with OpenAPI operations, the operations should appear +// in the parent's OpenAPI spec with prefixed paths and preserved metadata. +// +// **Validates: Requirements 4.1, 4.2** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: Nested routes appear in OpenAPI spec with prefixed paths + /// + /// For any router with routes nested under a prefix, all routes should + /// appear in the OpenAPI spec with the prefix prepended to their paths. + #[test] + fn prop_nested_routes_in_openapi_spec( + // 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 through RustApi + let nested_router = Router::new().route(&route_path, get(handler)); + let app = RustApi::new().nest(&prefix, nested_router); + + // Build expected prefixed path for OpenAPI (uses {param} format) + let expected_openapi_path = format!("{}{}", prefix, route_path); + + // Get the OpenAPI spec + let spec = app.openapi_spec(); + + // Property: The prefixed route should exist in OpenAPI paths + prop_assert!( + spec.paths.contains_key(&expected_openapi_path), + "Expected OpenAPI path '{}' not found. Available paths: {:?}", + expected_openapi_path, + spec.paths.keys().collect::>() + ); + + // Property: The path item should have a GET operation + let path_item = spec.paths.get(&expected_openapi_path).unwrap(); + prop_assert!( + path_item.get.is_some(), + "GET operation should exist for path '{}'", + expected_openapi_path + ); + } + + /// Property: Multiple HTTP methods are preserved in OpenAPI spec after nesting + /// + /// For any router with routes having multiple HTTP methods, nesting should + /// preserve all method operations in the OpenAPI spec. + #[test] + fn prop_multiple_methods_preserved_in_openapi( + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), + route_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), + ) { + async fn get_handler() -> &'static str { "get" } + async fn post_handler() -> &'static str { "post" } + + // Build the prefix and route path + let prefix = format!("/{}", prefix_segments.join("/")); + let route_path = format!("/{}", route_segments.join("/")); + + // Create nested router with both GET and POST using separate routes + // Since MethodRouter doesn't have chaining methods, we create two routes + let get_route_path = format!("{}/get", route_path); + let post_route_path = format!("{}/post", route_path); + let nested_router = Router::new() + .route(&get_route_path, get(get_handler)) + .route(&post_route_path, post(post_handler)); + let app = RustApi::new().nest(&prefix, nested_router); + + // Build expected prefixed paths for OpenAPI + let expected_get_path = format!("{}{}", prefix, get_route_path); + let expected_post_path = format!("{}{}", prefix, post_route_path); + + // Get the OpenAPI spec + let spec = app.openapi_spec(); + + // Property: Both paths should exist + prop_assert!( + spec.paths.contains_key(&expected_get_path), + "Expected OpenAPI path '{}' not found", + expected_get_path + ); + prop_assert!( + spec.paths.contains_key(&expected_post_path), + "Expected OpenAPI path '{}' not found", + expected_post_path + ); + + // Property: GET operation should exist on get path + let get_path_item = spec.paths.get(&expected_get_path).unwrap(); + prop_assert!( + get_path_item.get.is_some(), + "GET operation should exist for path '{}'", + expected_get_path + ); + + // Property: POST operation should exist on post path + let post_path_item = spec.paths.get(&expected_post_path).unwrap(); + prop_assert!( + post_path_item.post.is_some(), + "POST operation should exist for path '{}'", + expected_post_path + ); + } + + /// Property: Path parameters are added to OpenAPI operations after nesting + /// + /// For any nested route with path parameters, the OpenAPI operation should + /// include the path parameters. + #[test] + fn prop_path_params_in_openapi_after_nesting( + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), + param_name in "[a-z][a-z0-9]{0,5}", + ) { + async fn handler() -> &'static str { "handler" } + + // Build the prefix and route path with parameter + let prefix = format!("/{}", prefix_segments.join("/")); + let route_path = format!("/{{{}}}", param_name); + + // Create nested router + let nested_router = Router::new().route(&route_path, get(handler)); + let app = RustApi::new().nest(&prefix, nested_router); + + // Build expected prefixed path for OpenAPI + let expected_openapi_path = format!("{}{}", prefix, route_path); + + // Get the OpenAPI spec + let spec = app.openapi_spec(); + + // Property: The path should exist + prop_assert!( + spec.paths.contains_key(&expected_openapi_path), + "Expected OpenAPI path '{}' not found", + expected_openapi_path + ); + + // Property: The GET operation should have the path parameter + let path_item = spec.paths.get(&expected_openapi_path).unwrap(); + let get_op = path_item.get.as_ref().unwrap(); + + prop_assert!( + !get_op.parameters.is_empty(), + "Operation should have parameters for path '{}'", + expected_openapi_path + ); + + let params = &get_op.parameters; + let has_param = params.iter().any(|p| p.name == param_name && p.location == "path"); + prop_assert!( + has_param, + "Path parameter '{}' should exist in operation parameters. Found: {:?}", + param_name, + params.iter().map(|p| &p.name).collect::>() + ); + } +} + +// **Feature: router-nesting, Property 13: RustApi Integration** +// +// For any router nested through `RustApi::new().nest()`, the behavior should be +// identical to nesting through `Router::new().nest()`, and routes should appear +// in the OpenAPI spec. +// +// **Validates: Requirements 6.1, 6.2** +proptest! { + #![proptest_config(ProptestConfig::with_cases(100))] + + /// Property: RustApi::nest delegates to Router::nest and produces identical route registration + /// + /// For any router with routes nested under a prefix, nesting through RustApi + /// should produce the same route registration as nesting through Router directly. + #[test] + fn prop_rustapi_nest_delegates_to_router_nest( + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), + 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 + let nested_router_for_rustapi = Router::new().route(&route_path, get(handler)); + let nested_router_for_router = Router::new().route(&route_path, get(handler)); + + // Nest through RustApi + let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi); + let rustapi_router = rustapi_app.into_router(); + + // Nest through Router directly + let router_app = Router::new().nest(&prefix, nested_router_for_router); + + // Property: Both should have the same registered routes + let rustapi_routes = rustapi_router.registered_routes(); + let router_routes = router_app.registered_routes(); + + prop_assert_eq!( + rustapi_routes.len(), + router_routes.len(), + "RustApi and Router should have same number of routes" + ); + + // Property: All routes from Router should exist in RustApi + for (path, info) in router_routes { + prop_assert!( + rustapi_routes.contains_key(path), + "Route '{}' from Router should exist in RustApi routes", + path + ); + + let rustapi_info = rustapi_routes.get(path).unwrap(); + prop_assert_eq!( + &info.path, &rustapi_info.path, + "Display paths should match for route '{}'", + path + ); + prop_assert_eq!( + info.methods.len(), rustapi_info.methods.len(), + "Method count should match for route '{}'", + path + ); + } + } + + /// Property: RustApi::nest includes nested routes in OpenAPI spec + /// + /// For any router with routes nested through RustApi, all routes should + /// appear in the OpenAPI specification with prefixed paths. + #[test] + fn prop_rustapi_nest_includes_routes_in_openapi( + prefix_segments in prop::collection::vec("[a-z][a-z0-9]{0,5}", 1..3), + 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 through RustApi + let nested_router = Router::new().route(&route_path, get(handler)); + let app = RustApi::new().nest(&prefix, nested_router); + + // Build expected prefixed path for OpenAPI + let expected_openapi_path = format!("{}{}", prefix, route_path); + + // Get the OpenAPI spec + let spec = app.openapi_spec(); + + // Property: The prefixed route should exist in OpenAPI paths + prop_assert!( + spec.paths.contains_key(&expected_openapi_path), + "Expected OpenAPI path '{}' not found. Available paths: {:?}", + expected_openapi_path, + spec.paths.keys().collect::>() + ); + + // Property: The path item should have a GET operation + let path_item = spec.paths.get(&expected_openapi_path).unwrap(); + prop_assert!( + path_item.get.is_some(), + "GET operation should exist for path '{}'", + expected_openapi_path + ); + } + + /// Property: RustApi::nest route matching is identical to Router::nest + /// + /// For any nested route, matching through RustApi should produce the same + /// result as matching through Router directly. + #[test] + fn prop_rustapi_nest_route_matching_identical( + 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), + param_value in "[a-z0-9]{1,10}", + ) { + use crate::router::RouteMatch; + + async fn handler() -> &'static str { "handler" } + + // Build the prefix and route path with parameter + let prefix = format!("/{}", prefix_segments.join("/")); + let route_path = format!("/{}/{{id}}", route_segments.join("/")); + + // Create nested routers + let nested_router_for_rustapi = Router::new().route(&route_path, get(handler)); + let nested_router_for_router = Router::new().route(&route_path, get(handler)); + + // Nest through both RustApi and Router + let rustapi_app = RustApi::new().nest(&prefix, nested_router_for_rustapi); + let rustapi_router = rustapi_app.into_router(); + let router_app = Router::new().nest(&prefix, nested_router_for_router); + + // Build the full path to match + let full_path = format!("{}/{}/{}", prefix, route_segments.join("/"), param_value); + + // Match through both + let rustapi_match = rustapi_router.match_route(&full_path, &Method::GET); + let router_match = router_app.match_route(&full_path, &Method::GET); + + // Property: Both should return Found with same parameters + match (rustapi_match, router_match) { + (RouteMatch::Found { params: rustapi_params, .. }, RouteMatch::Found { params: router_params, .. }) => { + prop_assert_eq!( + rustapi_params.len(), + router_params.len(), + "Parameter count should match" + ); + for (key, value) in &router_params { + prop_assert!( + rustapi_params.contains_key(key), + "RustApi should have parameter '{}'", + key + ); + prop_assert_eq!( + rustapi_params.get(key).unwrap(), + value, + "Parameter '{}' value should match", + key + ); + } + } + (rustapi_result, router_result) => { + prop_assert!( + false, + "Both should return Found, but RustApi returned {:?} and Router returned {:?}", + match rustapi_result { + RouteMatch::Found { .. } => "Found", + RouteMatch::NotFound => "NotFound", + RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed", + }, + match router_result { + RouteMatch::Found { .. } => "Found", + RouteMatch::NotFound => "NotFound", + RouteMatch::MethodNotAllowed { .. } => "MethodNotAllowed", + } + ); + } + } + } +} + +/// Unit test: Verify OpenAPI operations are propagated during nesting +#[test] +fn test_openapi_operations_propagated_during_nesting() { + async fn list_users() -> &'static str { + "list users" + } + async fn get_user() -> &'static str { + "get user" + } + async fn create_user() -> &'static str { + "create user" + } + + // Create nested router with multiple routes + // Note: We use separate routes since MethodRouter doesn't support chaining + let users_router = Router::new() + .route("/", get(list_users)) + .route("/create", post(create_user)) + .route("/{id}", get(get_user)); + + // Nest under /api/v1/users + let app = RustApi::new().nest("/api/v1/users", users_router); + + let spec = app.openapi_spec(); + + // Verify /api/v1/users path exists with GET + assert!( + spec.paths.contains_key("/api/v1/users"), + "Should have /api/v1/users path" + ); + let users_path = spec.paths.get("/api/v1/users").unwrap(); + assert!(users_path.get.is_some(), "Should have GET operation"); + + // Verify /api/v1/users/create path exists with POST + assert!( + spec.paths.contains_key("/api/v1/users/create"), + "Should have /api/v1/users/create path" + ); + let create_path = spec.paths.get("/api/v1/users/create").unwrap(); + assert!(create_path.post.is_some(), "Should have POST operation"); + + // Verify /api/v1/users/{id} path exists with GET + assert!( + spec.paths.contains_key("/api/v1/users/{id}"), + "Should have /api/v1/users/{{id}} path" + ); + let user_path = spec.paths.get("/api/v1/users/{id}").unwrap(); + assert!( + user_path.get.is_some(), + "Should have GET operation for user by id" + ); + + // Verify path parameter is added + let get_user_op = user_path.get.as_ref().unwrap(); + assert!(!get_user_op.parameters.is_empty(), "Should have parameters"); + let params = &get_user_op.parameters; + assert!( + params + .iter() + .any(|p| p.name == "id" && p.location == "path"), + "Should have 'id' path parameter" + ); +} + +/// Unit test: Verify nested routes don't appear without nesting +#[test] +fn test_openapi_spec_empty_without_routes() { + let app = RustApi::new(); + let spec = app.openapi_spec(); + + // Should have no paths (except potentially default ones) + assert!( + spec.paths.is_empty(), + "OpenAPI spec should have no paths without routes" + ); +} + +/// Unit test: Verify RustApi::nest delegates correctly to Router::nest +/// +/// **Feature: router-nesting, Property 13: RustApi Integration** +/// **Validates: Requirements 6.1, 6.2** +#[test] +fn test_rustapi_nest_delegates_to_router_nest() { + use crate::router::RouteMatch; + + async fn list_users() -> &'static str { + "list users" + } + async fn get_user() -> &'static str { + "get user" + } + async fn create_user() -> &'static str { + "create user" + } + + // Create nested router with multiple routes + let users_router = Router::new() + .route("/", get(list_users)) + .route("/create", post(create_user)) + .route("/{id}", get(get_user)); + + // Nest through RustApi + let app = RustApi::new().nest("/api/v1/users", users_router); + let router = app.into_router(); + + // Verify routes are registered correctly + let routes = router.registered_routes(); + assert_eq!(routes.len(), 3, "Should have 3 routes registered"); + + // Verify route paths + assert!( + routes.contains_key("/api/v1/users"), + "Should have /api/v1/users route" + ); + assert!( + routes.contains_key("/api/v1/users/create"), + "Should have /api/v1/users/create route" + ); + assert!( + routes.contains_key("/api/v1/users/:id"), + "Should have /api/v1/users/:id route" + ); + + // Verify route matching works + match router.match_route("/api/v1/users", &Method::GET) { + RouteMatch::Found { params, .. } => { + assert!(params.is_empty(), "Root route should have no params"); + } + _ => panic!("GET /api/v1/users should be found"), + } + + match router.match_route("/api/v1/users/create", &Method::POST) { + RouteMatch::Found { params, .. } => { + assert!(params.is_empty(), "Create route should have no params"); + } + _ => panic!("POST /api/v1/users/create should be found"), + } + + match router.match_route("/api/v1/users/123", &Method::GET) { + RouteMatch::Found { params, .. } => { + assert_eq!( + params.get("id"), + Some(&"123".to_string()), + "Should extract id param" + ); + } + _ => panic!("GET /api/v1/users/123 should be found"), + } + + // Verify method not allowed + match router.match_route("/api/v1/users", &Method::DELETE) { + RouteMatch::MethodNotAllowed { allowed } => { + assert!(allowed.contains(&Method::GET), "Should allow GET"); + } + _ => panic!("DELETE /api/v1/users should return MethodNotAllowed"), + } +} + +/// Unit test: Verify RustApi::nest includes routes in OpenAPI spec +/// +/// **Feature: router-nesting, Property 13: RustApi Integration** +/// **Validates: Requirements 6.1, 6.2** +#[test] +fn test_rustapi_nest_includes_routes_in_openapi_spec() { + async fn list_items() -> &'static str { + "list items" + } + async fn get_item() -> &'static str { + "get item" + } + + // Create nested router + let items_router = Router::new() + .route("/", get(list_items)) + .route("/{item_id}", get(get_item)); + + // Nest through RustApi + let app = RustApi::new().nest("/api/items", items_router); + + // Verify OpenAPI spec + let spec = app.openapi_spec(); + + // Verify paths exist + assert!( + spec.paths.contains_key("/api/items"), + "Should have /api/items in OpenAPI" + ); + assert!( + spec.paths.contains_key("/api/items/{item_id}"), + "Should have /api/items/{{item_id}} in OpenAPI" + ); + + // Verify operations + let list_path = spec.paths.get("/api/items").unwrap(); + assert!( + list_path.get.is_some(), + "Should have GET operation for /api/items" + ); + + let get_path = spec.paths.get("/api/items/{item_id}").unwrap(); + assert!( + get_path.get.is_some(), + "Should have GET operation for /api/items/{{item_id}}" + ); + + // Verify path parameter is added + let get_op = get_path.get.as_ref().unwrap(); + assert!(!get_op.parameters.is_empty(), "Should have parameters"); + let params = &get_op.parameters; + assert!( + params + .iter() + .any(|p| p.name == "item_id" && p.location == "path"), + "Should have 'item_id' path parameter" + ); +} diff --git a/crates/rustapi-core/src/app/types.rs b/crates/rustapi-core/src/app/types.rs new file mode 100644 index 00000000..2d0fa11e --- /dev/null +++ b/crates/rustapi-core/src/app/types.rs @@ -0,0 +1,21 @@ +use crate::events::LifecycleHooks; +use crate::interceptor::InterceptorChain; +use crate::middleware::LayerStack; +use crate::router::Router; + +pub struct RustApi { + pub(super) router: Router, + pub(super) openapi_spec: rustapi_openapi::OpenApiSpec, + pub(super) layers: LayerStack, + pub(super) body_limit: Option, + pub(super) interceptors: InterceptorChain, + pub(super) lifecycle_hooks: LifecycleHooks, + pub(super) hot_reload: bool, + #[cfg(feature = "http3")] + pub(super) http3_config: Option, + pub(super) health_check: Option, + pub(super) health_endpoint_config: Option, + pub(super) status_config: Option, + #[cfg(feature = "dashboard")] + pub(super) dashboard_config: Option, +} diff --git a/crates/rustapi-core/src/router/conflict.rs b/crates/rustapi-core/src/router/conflict.rs new file mode 100644 index 00000000..b0cf5413 --- /dev/null +++ b/crates/rustapi-core/src/router/conflict.rs @@ -0,0 +1,73 @@ +use http::Method; + +/// Information about a registered route for conflict detection +#[derive(Debug, Clone)] +pub struct RouteInfo { + /// The original path pattern (e.g., "/users/{id}") + pub path: String, + /// The HTTP methods registered for this path + pub methods: Vec, +} + +/// Error returned when a route conflict is detected +#[derive(Debug, Clone)] +pub struct RouteConflictError { + /// The path that was being registered + pub new_path: String, + /// The HTTP method that conflicts + pub method: Option, + /// The existing path that conflicts + pub existing_path: String, + /// Detailed error message from the underlying router + pub details: String, +} + +impl std::fmt::Display for RouteConflictError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!( + f, + "\nÔò¡ÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔò«" + )?; + writeln!( + f, + "Ôöé ROUTE CONFLICT DETECTED Ôöé" + )?; + writeln!( + f, + "Ôò░ÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔò»" + )?; + writeln!(f)?; + writeln!(f, " Conflicting routes:")?; + writeln!(f, " ÔåÆ Existing: {}", self.existing_path)?; + writeln!(f, " ÔåÆ New: {}", self.new_path)?; + writeln!(f)?; + if let Some(ref method) = self.method { + writeln!(f, " HTTP Method: {}", method)?; + writeln!(f)?; + } + writeln!(f, " Details: {}", self.details)?; + writeln!(f)?; + writeln!(f, " How to resolve:")?; + writeln!(f, " 1. Use different path patterns for each route")?; + writeln!( + f, + " 2. If paths must be similar, ensure parameter names differ" + )?; + writeln!( + f, + " 3. Consider using different HTTP methods if appropriate" + )?; + writeln!(f)?; + writeln!(f, " Example:")?; + writeln!(f, " Instead of:")?; + writeln!(f, " .route(\"/users/{{id}}\", get(handler1))")?; + writeln!(f, " .route(\"/users/{{user_id}}\", get(handler2))")?; + writeln!(f)?; + writeln!(f, " Use:")?; + writeln!(f, " .route(\"/users/{{id}}\", get(handler1))")?; + writeln!(f, " .route(\"/users/{{id}}/profile\", get(handler2))")?; + Ok(()) + } +} + +impl std::error::Error for RouteConflictError {} diff --git a/crates/rustapi-core/src/router/core.rs b/crates/rustapi-core/src/router/core.rs new file mode 100644 index 00000000..7e589bd3 --- /dev/null +++ b/crates/rustapi-core/src/router/core.rs @@ -0,0 +1,362 @@ +use super::conflict::{RouteConflictError, RouteInfo}; +use super::match_::{ + convert_path_params, normalize_path_for_comparison, normalize_prefix, RouteMatch, +}; +use super::method_router::MethodRouter; +use crate::path_params::PathParams; +use crate::typed_path::TypedPath; +use http::{Extensions, Method}; +use matchit::Router as MatchitRouter; +use std::collections::HashMap; +use std::sync::Arc; + +/// Main router +#[derive(Clone)] +pub struct Router { + inner: MatchitRouter, + pub(super) state: Arc, + /// Track registered routes for conflict detection + registered_routes: HashMap, + /// Store MethodRouters for nesting support (keyed by matchit path) + method_routers: HashMap, + /// Track state type IDs for merging (type name -> whether it's set) + /// This is a workaround since Extensions doesn't support iteration + state_type_ids: Vec, +} + +impl Router { + /// Create a new router + pub fn new() -> Self { + Self { + inner: MatchitRouter::new(), + state: Arc::new(Extensions::new()), + registered_routes: HashMap::new(), + method_routers: HashMap::new(), + state_type_ids: Vec::new(), + } + } + + /// Add a typed route using a TypedPath + pub fn typed(self, method_router: MethodRouter) -> Self { + self.route(P::PATH, method_router) + } + + /// Add a route + pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self { + // Convert {param} style to :param for matchit + let matchit_path = convert_path_params(path); + + // Get the methods being registered + let methods: Vec = method_router.handlers.keys().cloned().collect(); + + // Store a clone of the MethodRouter for nesting support + self.method_routers + .insert(matchit_path.clone(), method_router.clone()); + + match self.inner.insert(matchit_path.clone(), method_router) { + Ok(_) => { + // Track the registered route + self.registered_routes.insert( + matchit_path.clone(), + RouteInfo { + path: path.to_string(), + methods, + }, + ); + } + Err(e) => { + // Remove the method_router we just added since registration failed + self.method_routers.remove(&matchit_path); + + // Find the existing conflicting route + let existing_path = self + .find_conflicting_route(&matchit_path) + .map(|info| info.path.clone()) + .unwrap_or_else(|| "".to_string()); + + let conflict_error = RouteConflictError { + new_path: path.to_string(), + method: methods.first().cloned(), + existing_path, + details: e.to_string(), + }; + + panic!("{}", conflict_error); + } + } + self + } + + /// Find a conflicting route by checking registered routes + fn find_conflicting_route(&self, matchit_path: &str) -> Option<&RouteInfo> { + // Try to find an exact match first + if let Some(info) = self.registered_routes.get(matchit_path) { + return Some(info); + } + + // Try to find a route that would conflict (same structure but different param names) + let normalized_new = normalize_path_for_comparison(matchit_path); + + for (registered_path, info) in &self.registered_routes { + let normalized_existing = normalize_path_for_comparison(registered_path); + if normalized_new == normalized_existing { + return Some(info); + } + } + + None + } + + /// Add application state + pub fn state(mut self, state: S) -> Self { + let type_id = std::any::TypeId::of::(); + let extensions = Arc::make_mut(&mut self.state); + extensions.insert(state); + if !self.state_type_ids.contains(&type_id) { + self.state_type_ids.push(type_id); + } + self + } + + /// Check if state of a given type exists + pub fn has_state(&self) -> bool { + self.state_type_ids.contains(&std::any::TypeId::of::()) + } + + /// Get state type IDs (for testing and debugging) + pub fn state_type_ids(&self) -> &[std::any::TypeId] { + &self.state_type_ids + } + + /// Nest another router under a prefix + /// + /// All routes from the nested router will be registered with the prefix + /// prepended to their paths. State from the nested router is merged into + /// the parent router (parent state takes precedence for type conflicts). + /// + /// # State Merging + /// + /// When nesting routers with state: + /// - If the parent router has state of type T, it is preserved (parent wins) + /// - If only the nested router has state of type T, it is added to the parent + /// - State type tracking is merged to enable proper conflict detection + /// + /// Note: Due to limitations of `http::Extensions`, automatic state merging + /// requires using the `merge_state` method for specific types. + /// + /// # Example + /// + /// ```rust,ignore + /// use rustapi_core::{Router, get}; + /// + /// 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); + /// + /// // Routes are now: + /// // GET /api/users/ + /// // GET /api/users/{id} + /// ``` + /// + /// # Nesting with State + /// + /// The `nest` method automatically tracks state types from the nested router to prevent + /// conflicts, but it does NOT automatically merge the state values instance by instance. + /// You should distinctively add state to the parent, or use `merge_state` if you want + /// to pull a specific state object from the child. + /// + /// ```rust,ignore + /// use rustapi_core::Router; + /// use std::sync::Arc; + /// + /// #[derive(Clone)] + /// struct Database { /* ... */ } + /// + /// let db = Database { /* ... */ }; + /// + /// // Option 1: Add state to the parent (Recommended) + /// let api = Router::new() + /// .nest("/v1", Router::new() + /// .route("/users", get(list_users))) // Needs Database + /// .state(db); + /// + /// // Option 2: Define specific state in sub-router and merge explicitly + /// let sub_router = Router::new() + /// .state(Database { /* ... */ }) + /// .route("/items", get(list_items)); + /// + /// let app = Router::new() + /// .merge_state::(&sub_router) // Pulls Database from sub_router + /// .nest("/api", sub_router); + /// ``` + pub fn nest(mut self, prefix: &str, router: Router) -> Self { + // 1. Normalize the prefix + let normalized_prefix = normalize_prefix(prefix); + + // 2. Merge state type IDs from nested router + // Parent state takes precedence - we only track types, actual values + // are handled by merge_state calls or by the user adding state to parent + for type_id in &router.state_type_ids { + if !self.state_type_ids.contains(type_id) { + self.state_type_ids.push(*type_id); + } + } + + // 3. Collect routes from the nested router before consuming it + // We need to iterate over registered_routes and get the corresponding MethodRouters + let nested_routes: Vec<(String, RouteInfo, MethodRouter)> = router + .registered_routes + .into_iter() + .filter_map(|(matchit_path, route_info)| { + router + .method_routers + .get(&matchit_path) + .map(|mr| (matchit_path, route_info, mr.clone())) + }) + .collect(); + + // 4. Register each nested route with the prefix + for (matchit_path, route_info, method_router) in nested_routes { + // Build the prefixed path + // The matchit_path already has the :param format + // The route_info.path has the {param} format + let prefixed_matchit_path = if matchit_path == "/" { + normalized_prefix.clone() + } else { + format!("{}{}", normalized_prefix, matchit_path) + }; + + let prefixed_display_path = if route_info.path == "/" { + normalized_prefix.clone() + } else { + format!("{}{}", normalized_prefix, route_info.path) + }; + + // Store the MethodRouter for future nesting + self.method_routers + .insert(prefixed_matchit_path.clone(), method_router.clone()); + + // Try to insert into the matchit router + match self + .inner + .insert(prefixed_matchit_path.clone(), method_router) + { + Ok(_) => { + // Track the registered route + self.registered_routes.insert( + prefixed_matchit_path, + RouteInfo { + path: prefixed_display_path, + methods: route_info.methods, + }, + ); + } + Err(e) => { + // Remove the method_router we just added since registration failed + self.method_routers.remove(&prefixed_matchit_path); + + // Find the existing conflicting route + let existing_path = self + .find_conflicting_route(&prefixed_matchit_path) + .map(|info| info.path.clone()) + .unwrap_or_else(|| "".to_string()); + + let conflict_error = RouteConflictError { + new_path: prefixed_display_path, + method: route_info.methods.first().cloned(), + existing_path, + details: e.to_string(), + }; + + panic!("{}", conflict_error); + } + } + } + + self + } + + /// Merge state from another router into this one + /// + /// This method allows explicit state merging when nesting routers. + /// Parent state takes precedence - if the parent already has state of type S, + /// the nested state is ignored. + /// + /// # Example + /// + /// ```rust,ignore + /// #[derive(Clone)] + /// struct DbPool(String); + /// + /// let nested = Router::new().state(DbPool("nested".to_string())); + /// let parent = Router::new() + /// .merge_state::(&nested); // Adds DbPool from nested + /// ``` + pub fn merge_state(mut self, other: &Router) -> Self { + let type_id = std::any::TypeId::of::(); + + // Parent wins - only merge if parent doesn't have this state type + if !self.state_type_ids.contains(&type_id) { + // Try to get the state from the other router + if let Some(state) = other.state.get::() { + let extensions = Arc::make_mut(&mut self.state); + extensions.insert(state.clone()); + self.state_type_ids.push(type_id); + } + } + + self + } + + /// Match a request and return the handler + params + pub fn match_route(&self, path: &str, method: &Method) -> RouteMatch<'_> { + match self.inner.at(path) { + Ok(matched) => { + let method_router = matched.value; + + if let Some(handler) = method_router.get_handler(method) { + // Use stack-optimized PathParams (avoids heap allocation for ≤4 params) + let params: PathParams = matched + .params + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + RouteMatch::Found { handler, params } + } else { + RouteMatch::MethodNotAllowed { + allowed: method_router.allowed_methods(), + } + } + } + Err(_) => RouteMatch::NotFound, + } + } + + /// Get shared state + pub fn state_ref(&self) -> Arc { + self.state.clone() + } + + /// Get registered routes (for testing and debugging) + pub fn registered_routes(&self) -> &HashMap { + &self.registered_routes + } + + /// Get method routers (for OpenAPI integration during nesting) + pub fn method_routers(&self) -> &HashMap { + &self.method_routers + } +} + +impl Default for Router { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/rustapi-core/src/router/match_.rs b/crates/rustapi-core/src/router/match_.rs new file mode 100644 index 00000000..0f6c6b31 --- /dev/null +++ b/crates/rustapi-core/src/router/match_.rs @@ -0,0 +1,103 @@ +use crate::handler::BoxedHandler; +use crate::path_params::PathParams; +use http::Method; + +/// Result of route matching +pub enum RouteMatch<'a> { + Found { + handler: &'a BoxedHandler, + params: PathParams, + }, + NotFound, + MethodNotAllowed { + allowed: Vec, + }, +} + +/// Convert {param} style to :param for matchit +pub(crate) fn convert_path_params(path: &str) -> String { + let mut result = String::with_capacity(path.len()); + + for ch in path.chars() { + match ch { + '{' => { + result.push(':'); + } + '}' => { + // Skip closing brace + } + _ => { + result.push(ch); + } + } + } + + result +} + +/// Normalize a path for conflict comparison by replacing parameter names with a placeholder +pub(crate) fn normalize_path_for_comparison(path: &str) -> String { + let mut result = String::with_capacity(path.len()); + let mut in_param = false; + + for ch in path.chars() { + match ch { + ':' => { + in_param = true; + result.push_str(":_"); + } + '/' => { + in_param = false; + result.push('/'); + } + _ if in_param => { + // Skip parameter name characters + } + _ => { + result.push(ch); + } + } + } + + result +} + +/// Normalize a prefix for router nesting. +/// +/// Ensures the prefix: +/// - Starts with exactly one leading slash +/// - Has no trailing slash (unless it's just "/") +/// - Has no double slashes +/// +/// # Examples +/// +/// ```ignore +/// assert_eq!(normalize_prefix("api"), "/api"); +/// assert_eq!(normalize_prefix("/api"), "/api"); +/// assert_eq!(normalize_prefix("/api/"), "/api"); +/// assert_eq!(normalize_prefix("//api//"), "/api"); +/// assert_eq!(normalize_prefix(""), "/"); +/// ``` +pub(crate) fn normalize_prefix(prefix: &str) -> String { + // Handle empty string + if prefix.is_empty() { + return "/".to_string(); + } + + // Split by slashes and filter out empty segments (handles multiple slashes) + let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect(); + + // If no segments after filtering, return root + if segments.is_empty() { + return "/".to_string(); + } + + // Build the normalized prefix with leading slash + let mut result = String::with_capacity(prefix.len() + 1); + for segment in segments { + result.push('/'); + result.push_str(segment); + } + + result +} diff --git a/crates/rustapi-core/src/router/method_router.rs b/crates/rustapi-core/src/router/method_router.rs new file mode 100644 index 00000000..dc7be2fe --- /dev/null +++ b/crates/rustapi-core/src/router/method_router.rs @@ -0,0 +1,253 @@ +use crate::handler::{into_boxed_handler, BoxedHandler, Handler}; +use http::Method; +use rustapi_openapi::Operation; +use std::collections::HashMap; + +/// HTTP method router for a single path +pub struct MethodRouter { + pub(super) handlers: HashMap, + pub(crate) operations: HashMap, + pub(crate) component_registrars: Vec, +} + +impl Clone for MethodRouter { + fn clone(&self) -> Self { + Self { + handlers: self.handlers.clone(), + operations: self.operations.clone(), + component_registrars: self.component_registrars.clone(), + } + } +} + +impl MethodRouter { + /// Create a new empty method router + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + operations: HashMap::new(), + component_registrars: Vec::new(), + } + } + + /// Add a handler for a specific method + fn on( + mut self, + method: Method, + handler: BoxedHandler, + operation: Operation, + component_registrar: fn(&mut rustapi_openapi::OpenApiSpec), + ) -> Self { + self.handlers.insert(method.clone(), handler); + self.operations.insert(method, operation); + self.component_registrars.push(component_registrar); + self + } + + /// Get handler for a method + pub(crate) fn get_handler(&self, method: &Method) -> Option<&BoxedHandler> { + self.handlers.get(method) + } + + /// Get allowed methods for 405 response + pub(crate) fn allowed_methods(&self) -> Vec { + self.handlers.keys().cloned().collect() + } + + /// Create from pre-boxed handlers (internal use) + pub(crate) fn from_boxed(handlers: HashMap) -> Self { + Self { + handlers, + operations: HashMap::new(), // Operations lost when using raw boxed handlers for now + component_registrars: Vec::new(), + } + } + + /// Insert a pre-boxed handler and its OpenAPI operation (internal use). + /// + /// Panics if the same method is inserted twice for the same path. + pub(crate) fn insert_boxed_with_operation( + &mut self, + method: Method, + handler: BoxedHandler, + operation: Operation, + component_registrar: fn(&mut rustapi_openapi::OpenApiSpec), + ) { + if self.handlers.contains_key(&method) { + panic!( + "Duplicate handler for method {} on the same path", + method.as_str() + ); + } + + self.handlers.insert(method.clone(), handler); + self.operations.insert(method, operation); + self.component_registrars.push(component_registrar); + } + + /// Add a GET handler + pub fn get(self, handler: H) -> Self + where + H: Handler, + T: 'static, + { + let mut op = Operation::new(); + H::update_operation(&mut op); + self.on( + Method::GET, + into_boxed_handler(handler), + op, + >::register_components, + ) + } + + /// Add a POST handler + pub fn post(self, handler: H) -> Self + where + H: Handler, + T: 'static, + { + let mut op = Operation::new(); + H::update_operation(&mut op); + self.on( + Method::POST, + into_boxed_handler(handler), + op, + >::register_components, + ) + } + + /// Add a PUT handler + pub fn put(self, handler: H) -> Self + where + H: Handler, + T: 'static, + { + let mut op = Operation::new(); + H::update_operation(&mut op); + self.on( + Method::PUT, + into_boxed_handler(handler), + op, + >::register_components, + ) + } + + /// Add a PATCH handler + pub fn patch(self, handler: H) -> Self + where + H: Handler, + T: 'static, + { + let mut op = Operation::new(); + H::update_operation(&mut op); + self.on( + Method::PATCH, + into_boxed_handler(handler), + op, + >::register_components, + ) + } + + /// Add a DELETE handler + pub fn delete(self, handler: H) -> Self + where + H: Handler, + T: 'static, + { + let mut op = Operation::new(); + H::update_operation(&mut op); + self.on( + Method::DELETE, + into_boxed_handler(handler), + op, + >::register_components, + ) + } +} + +impl Default for MethodRouter { + fn default() -> Self { + Self::new() + } +} + +/// Create a GET route handler +pub fn get(handler: H) -> MethodRouter +where + H: Handler, + T: 'static, +{ + let mut op = Operation::new(); + H::update_operation(&mut op); + MethodRouter::new().on( + Method::GET, + into_boxed_handler(handler), + op, + >::register_components, + ) +} + +/// Create a POST route handler +pub fn post(handler: H) -> MethodRouter +where + H: Handler, + T: 'static, +{ + let mut op = Operation::new(); + H::update_operation(&mut op); + MethodRouter::new().on( + Method::POST, + into_boxed_handler(handler), + op, + >::register_components, + ) +} + +/// Create a PUT route handler +pub fn put(handler: H) -> MethodRouter +where + H: Handler, + T: 'static, +{ + let mut op = Operation::new(); + H::update_operation(&mut op); + MethodRouter::new().on( + Method::PUT, + into_boxed_handler(handler), + op, + >::register_components, + ) +} + +/// Create a PATCH route handler +pub fn patch(handler: H) -> MethodRouter +where + H: Handler, + T: 'static, +{ + let mut op = Operation::new(); + H::update_operation(&mut op); + MethodRouter::new().on( + Method::PATCH, + into_boxed_handler(handler), + op, + >::register_components, + ) +} + +/// Create a DELETE route handler +pub fn delete(handler: H) -> MethodRouter +where + H: Handler, + T: 'static, +{ + let mut op = Operation::new(); + H::update_operation(&mut op); + MethodRouter::new().on( + Method::DELETE, + into_boxed_handler(handler), + op, + >::register_components, + ) +} diff --git a/crates/rustapi-core/src/router/mod.rs b/crates/rustapi-core/src/router/mod.rs new file mode 100644 index 00000000..f6cc5cf9 --- /dev/null +++ b/crates/rustapi-core/src/router/mod.rs @@ -0,0 +1,18 @@ +//! Router implementation using radix tree (matchit) +//! +//! This module provides HTTP routing functionality for RustAPI. Routes are +//! registered using path patterns and HTTP method handlers. + +mod conflict; +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}; diff --git a/crates/rustapi-core/src/router.rs b/crates/rustapi-core/src/router/tests.rs similarity index 59% rename from crates/rustapi-core/src/router.rs rename to crates/rustapi-core/src/router/tests.rs index adb0c808..9e344e88 100644 --- a/crates/rustapi-core/src/router.rs +++ b/crates/rustapi-core/src/router/tests.rs @@ -1,1586 +1,766 @@ -//! Router implementation using radix tree (matchit) -//! -//! This module provides HTTP routing functionality for RustAPI. Routes are -//! registered using path patterns and HTTP method handlers. -//! -//! # Path Patterns -//! -//! Routes support dynamic path parameters using `{param}` syntax: -//! -//! - `/users` - Static path -//! - `/users/{id}` - Single parameter -//! - `/users/{user_id}/posts/{post_id}` - Multiple parameters -//! -//! # Example -//! -//! ```rust,ignore -//! use rustapi_core::{Router, get, post, put, delete}; -//! -//! async fn list_users() -> &'static str { "List users" } -//! async fn get_user() -> &'static str { "Get user" } -//! async fn create_user() -> &'static str { "Create user" } -//! async fn update_user() -> &'static str { "Update user" } -//! async fn delete_user() -> &'static str { "Delete user" } -//! -//! let router = Router::new() -//! .route("/users", get(list_users).post(create_user)) -//! .route("/users/{id}", get(get_user).put(update_user).delete(delete_user)); -//! ``` -//! -//! # Method Chaining -//! -//! Multiple HTTP methods can be registered for the same path using method chaining: -//! -//! ```rust,ignore -//! .route("/users", get(list).post(create)) -//! .route("/users/{id}", get(show).put(update).delete(destroy)) -//! ``` -//! -//! # Route Conflict Detection -//! -//! The router detects conflicting routes at registration time and provides -//! helpful error messages with resolution guidance. - -use crate::handler::{into_boxed_handler, BoxedHandler, Handler}; -use crate::path_params::PathParams; -use crate::typed_path::TypedPath; -use http::{Extensions, Method}; -use matchit::Router as MatchitRouter; -use rustapi_openapi::Operation; -use std::collections::HashMap; -use std::sync::Arc; - -/// Information about a registered route for conflict detection -#[derive(Debug, Clone)] -pub struct RouteInfo { - /// The original path pattern (e.g., "/users/{id}") - pub path: String, - /// The HTTP methods registered for this path - pub methods: Vec, +use super::{ + 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"); } -/// Error returned when a route conflict is detected -#[derive(Debug, Clone)] -pub struct RouteConflictError { - /// The path that was being registered - pub new_path: String, - /// The HTTP method that conflicts - pub method: Option, - /// The existing path that conflicts - pub existing_path: String, - /// Detailed error message from the underlying router - pub details: String, +#[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" + ); } -impl std::fmt::Display for RouteConflictError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!( - f, - "\n╭─────────────────────────────────────────────────────────────╮" - )?; - writeln!( - f, - "│ ROUTE CONFLICT DETECTED │" - )?; - writeln!( - f, - "╰─────────────────────────────────────────────────────────────╯" - )?; - writeln!(f)?; - writeln!(f, " Conflicting routes:")?; - writeln!(f, " → Existing: {}", self.existing_path)?; - writeln!(f, " → New: {}", self.new_path)?; - writeln!(f)?; - if let Some(ref method) = self.method { - writeln!(f, " HTTP Method: {}", method)?; - writeln!(f)?; - } - writeln!(f, " Details: {}", self.details)?; - writeln!(f)?; - writeln!(f, " How to resolve:")?; - writeln!(f, " 1. Use different path patterns for each route")?; - writeln!( - f, - " 2. If paths must be similar, ensure parameter names differ" - )?; - writeln!( - f, - " 3. Consider using different HTTP methods if appropriate" - )?; - writeln!(f)?; - writeln!(f, " Example:")?; - writeln!(f, " Instead of:")?; - writeln!(f, " .route(\"/users/{{id}}\", get(handler1))")?; - writeln!(f, " .route(\"/users/{{user_id}}\", get(handler2))")?; - writeln!(f)?; - writeln!(f, " Use:")?; - writeln!(f, " .route(\"/users/{{id}}\", get(handler1))")?; - writeln!(f, " .route(\"/users/{{id}}/profile\", get(handler2))")?; - Ok(()) - } -} - -impl std::error::Error for RouteConflictError {} - -/// HTTP method router for a single path -pub struct MethodRouter { - handlers: HashMap, - pub(crate) operations: HashMap, - pub(crate) component_registrars: Vec, +#[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("///"), "/"); } -impl Clone for MethodRouter { - fn clone(&self) -> Self { - Self { - handlers: self.handlers.clone(), - operations: self.operations.clone(), - component_registrars: self.component_registrars.clone(), - } +#[test] +#[should_panic(expected = "ROUTE CONFLICT DETECTED")] +fn test_route_conflict_detection() { + async fn handler1() -> &'static str { + "handler1" } -} - -impl MethodRouter { - /// Create a new empty method router - pub fn new() -> Self { - Self { - handlers: HashMap::new(), - operations: HashMap::new(), - component_registrars: Vec::new(), - } - } - - /// Add a handler for a specific method - fn on( - mut self, - method: Method, - handler: BoxedHandler, - operation: Operation, - component_registrar: fn(&mut rustapi_openapi::OpenApiSpec), - ) -> Self { - self.handlers.insert(method.clone(), handler); - self.operations.insert(method, operation); - self.component_registrars.push(component_registrar); - self + async fn handler2() -> &'static str { + "handler2" } - /// Get handler for a method - pub(crate) fn get_handler(&self, method: &Method) -> Option<&BoxedHandler> { - self.handlers.get(method) - } + let _router = Router::new() + .route("/users/{id}", get(handler1)) + .route("/users/{user_id}", get(handler2)); // This should panic +} - /// Get allowed methods for 405 response - pub(crate) fn allowed_methods(&self) -> Vec { - self.handlers.keys().cloned().collect() +#[test] +fn test_no_conflict_different_paths() { + async fn handler1() -> &'static str { + "handler1" } - - /// Create from pre-boxed handlers (internal use) - pub(crate) fn from_boxed(handlers: HashMap) -> Self { - Self { - handlers, - operations: HashMap::new(), // Operations lost when using raw boxed handlers for now - component_registrars: Vec::new(), - } + async fn handler2() -> &'static str { + "handler2" } - /// Insert a pre-boxed handler and its OpenAPI operation (internal use). - /// - /// Panics if the same method is inserted twice for the same path. - pub(crate) fn insert_boxed_with_operation( - &mut self, - method: Method, - handler: BoxedHandler, - operation: Operation, - component_registrar: fn(&mut rustapi_openapi::OpenApiSpec), - ) { - if self.handlers.contains_key(&method) { - panic!( - "Duplicate handler for method {} on the same path", - method.as_str() - ); - } + let router = Router::new() + .route("/users/{id}", get(handler1)) + .route("/users/{id}/profile", get(handler2)); - self.handlers.insert(method.clone(), handler); - self.operations.insert(method, operation); - self.component_registrars.push(component_registrar); - } - - /// Add a GET handler - pub fn get(self, handler: H) -> Self - where - H: Handler, - T: 'static, - { - let mut op = Operation::new(); - H::update_operation(&mut op); - self.on( - Method::GET, - into_boxed_handler(handler), - op, - >::register_components, - ) - } - - /// Add a POST handler - pub fn post(self, handler: H) -> Self - where - H: Handler, - T: 'static, - { - let mut op = Operation::new(); - H::update_operation(&mut op); - self.on( - Method::POST, - into_boxed_handler(handler), - op, - >::register_components, - ) - } - - /// Add a PUT handler - pub fn put(self, handler: H) -> Self - where - H: Handler, - T: 'static, - { - let mut op = Operation::new(); - H::update_operation(&mut op); - self.on( - Method::PUT, - into_boxed_handler(handler), - op, - >::register_components, - ) - } - - /// Add a PATCH handler - pub fn patch(self, handler: H) -> Self - where - H: Handler, - T: 'static, - { - let mut op = Operation::new(); - H::update_operation(&mut op); - self.on( - Method::PATCH, - into_boxed_handler(handler), - op, - >::register_components, - ) - } - - /// Add a DELETE handler - pub fn delete(self, handler: H) -> Self - where - H: Handler, - T: 'static, - { - let mut op = Operation::new(); - H::update_operation(&mut op); - self.on( - Method::DELETE, - into_boxed_handler(handler), - op, - >::register_components, - ) - } + assert_eq!(router.registered_routes().len(), 2); } -impl Default for MethodRouter { - fn default() -> Self { - Self::new() +#[test] +fn test_route_info_tracking() { + async fn handler() -> &'static str { + "handler" } -} -/// Create a GET route handler -pub fn get(handler: H) -> MethodRouter -where - H: Handler, - T: 'static, -{ - let mut op = Operation::new(); - H::update_operation(&mut op); - MethodRouter::new().on( - Method::GET, - into_boxed_handler(handler), - op, - >::register_components, - ) -} - -/// Create a POST route handler -pub fn post(handler: H) -> MethodRouter -where - H: Handler, - T: 'static, -{ - let mut op = Operation::new(); - H::update_operation(&mut op); - MethodRouter::new().on( - Method::POST, - into_boxed_handler(handler), - op, - >::register_components, - ) -} + let router = Router::new().route("/users/{id}", get(handler)); -/// Create a PUT route handler -pub fn put(handler: H) -> MethodRouter -where - H: Handler, - T: 'static, -{ - let mut op = Operation::new(); - H::update_operation(&mut op); - MethodRouter::new().on( - Method::PUT, - into_boxed_handler(handler), - op, - >::register_components, - ) -} + let routes = router.registered_routes(); + assert_eq!(routes.len(), 1); -/// Create a PATCH route handler -pub fn patch(handler: H) -> MethodRouter -where - H: Handler, - T: 'static, -{ - let mut op = Operation::new(); - H::update_operation(&mut op); - MethodRouter::new().on( - Method::PATCH, - into_boxed_handler(handler), - op, - >::register_components, - ) + 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); } -/// Create a DELETE route handler -pub fn delete(handler: H) -> MethodRouter -where - H: Handler, - T: 'static, -{ - let mut op = Operation::new(); - H::update_operation(&mut op); - MethodRouter::new().on( - Method::DELETE, - into_boxed_handler(handler), - op, - >::register_components, - ) -} - -/// Main router -#[derive(Clone)] -pub struct Router { - inner: MatchitRouter, - state: Arc, - /// Track registered routes for conflict detection - registered_routes: HashMap, - /// Store MethodRouters for nesting support (keyed by matchit path) - method_routers: HashMap, - /// Track state type IDs for merging (type name -> whether it's set) - /// This is a workaround since Extensions doesn't support iteration - state_type_ids: Vec, -} - -impl Router { - /// Create a new router - pub fn new() -> Self { - Self { - inner: MatchitRouter::new(), - state: Arc::new(Extensions::new()), - registered_routes: HashMap::new(), - method_routers: HashMap::new(), - state_type_ids: Vec::new(), - } +#[test] +fn test_basic_router_nesting() { + async fn list_users() -> &'static str { + "list users" } - - /// Add a typed route using a TypedPath - pub fn typed(self, method_router: MethodRouter) -> Self { - self.route(P::PATH, method_router) + async fn get_user() -> &'static str { + "get user" } - /// Add a route - pub fn route(mut self, path: &str, method_router: MethodRouter) -> Self { - // Convert {param} style to :param for matchit - let matchit_path = convert_path_params(path); + let users_router = Router::new() + .route("/", get(list_users)) + .route("/{id}", get(get_user)); - // Get the methods being registered - let methods: Vec = method_router.handlers.keys().cloned().collect(); + let app = Router::new().nest("/api/users", users_router); - // Store a clone of the MethodRouter for nesting support - self.method_routers - .insert(matchit_path.clone(), method_router.clone()); + let routes = app.registered_routes(); + assert_eq!(routes.len(), 2); - match self.inner.insert(matchit_path.clone(), method_router) { - Ok(_) => { - // Track the registered route - self.registered_routes.insert( - matchit_path.clone(), - RouteInfo { - path: path.to_string(), - methods, - }, - ); - } - Err(e) => { - // Remove the method_router we just added since registration failed - self.method_routers.remove(&matchit_path); - - // Find the existing conflicting route - let existing_path = self - .find_conflicting_route(&matchit_path) - .map(|info| info.path.clone()) - .unwrap_or_else(|| "".to_string()); - - let conflict_error = RouteConflictError { - new_path: path.to_string(), - method: methods.first().cloned(), - existing_path, - details: e.to_string(), - }; - - panic!("{}", conflict_error); - } - } - self - } + // Check that routes are registered with prefix + assert!(routes.contains_key("/api/users")); + assert!(routes.contains_key("/api/users/:id")); - /// Find a conflicting route by checking registered routes - fn find_conflicting_route(&self, matchit_path: &str) -> Option<&RouteInfo> { - // Try to find an exact match first - if let Some(info) = self.registered_routes.get(matchit_path) { - return Some(info); - } - - // Try to find a route that would conflict (same structure but different param names) - let normalized_new = normalize_path_for_comparison(matchit_path); + // Check display paths + let list_info = routes.get("/api/users").unwrap(); + assert_eq!(list_info.path, "/api/users"); - for (registered_path, info) in &self.registered_routes { - let normalized_existing = normalize_path_for_comparison(registered_path); - if normalized_new == normalized_existing { - return Some(info); - } - } + let get_info = routes.get("/api/users/:id").unwrap(); + assert_eq!(get_info.path, "/api/users/{id}"); +} - None +#[test] +fn test_nested_route_matching() { + async fn handler() -> &'static str { + "handler" } - /// Add application state - pub fn state(mut self, state: S) -> Self { - let type_id = std::any::TypeId::of::(); - let extensions = Arc::make_mut(&mut self.state); - extensions.insert(state); - if !self.state_type_ids.contains(&type_id) { - self.state_type_ids.push(type_id); - } - self - } - - /// Check if state of a given type exists - pub fn has_state(&self) -> bool { - self.state_type_ids.contains(&std::any::TypeId::of::()) - } - - /// Get state type IDs (for testing and debugging) - pub fn state_type_ids(&self) -> &[std::any::TypeId] { - &self.state_type_ids - } - - /// Nest another router under a prefix - /// - /// All routes from the nested router will be registered with the prefix - /// prepended to their paths. State from the nested router is merged into - /// the parent router (parent state takes precedence for type conflicts). - /// - /// # State Merging - /// - /// When nesting routers with state: - /// - If the parent router has state of type T, it is preserved (parent wins) - /// - If only the nested router has state of type T, it is added to the parent - /// - State type tracking is merged to enable proper conflict detection - /// - /// Note: Due to limitations of `http::Extensions`, automatic state merging - /// requires using the `merge_state` method for specific types. - /// - /// # Example - /// - /// ```rust,ignore - /// use rustapi_core::{Router, get}; - /// - /// 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); - /// - /// // Routes are now: - /// // GET /api/users/ - /// // GET /api/users/{id} - /// ``` - /// - /// # Nesting with State - /// - /// The `nest` method automatically tracks state types from the nested router to prevent - /// conflicts, but it does NOT automatically merge the state values instance by instance. - /// You should distinctively add state to the parent, or use `merge_state` if you want - /// to pull a specific state object from the child. - /// - /// ```rust,ignore - /// use rustapi_core::Router; - /// use std::sync::Arc; - /// - /// #[derive(Clone)] - /// struct Database { /* ... */ } - /// - /// let db = Database { /* ... */ }; - /// - /// // Option 1: Add state to the parent (Recommended) - /// let api = Router::new() - /// .nest("/v1", Router::new() - /// .route("/users", get(list_users))) // Needs Database - /// .state(db); - /// - /// // Option 2: Define specific state in sub-router and merge explicitly - /// let sub_router = Router::new() - /// .state(Database { /* ... */ }) - /// .route("/items", get(list_items)); - /// - /// let app = Router::new() - /// .merge_state::(&sub_router) // Pulls Database from sub_router - /// .nest("/api", sub_router); - /// ``` - pub fn nest(mut self, prefix: &str, router: Router) -> Self { - // 1. Normalize the prefix - let normalized_prefix = normalize_prefix(prefix); - - // 2. Merge state type IDs from nested router - // Parent state takes precedence - we only track types, actual values - // are handled by merge_state calls or by the user adding state to parent - for type_id in &router.state_type_ids { - if !self.state_type_ids.contains(type_id) { - self.state_type_ids.push(*type_id); - } - } + let users_router = Router::new().route("/{id}", get(handler)); - // 3. Collect routes from the nested router before consuming it - // We need to iterate over registered_routes and get the corresponding MethodRouters - let nested_routes: Vec<(String, RouteInfo, MethodRouter)> = router - .registered_routes - .into_iter() - .filter_map(|(matchit_path, route_info)| { - router - .method_routers - .get(&matchit_path) - .map(|mr| (matchit_path, route_info, mr.clone())) - }) - .collect(); - - // 4. Register each nested route with the prefix - for (matchit_path, route_info, method_router) in nested_routes { - // Build the prefixed path - // The matchit_path already has the :param format - // The route_info.path has the {param} format - let prefixed_matchit_path = if matchit_path == "/" { - normalized_prefix.clone() - } else { - format!("{}{}", normalized_prefix, matchit_path) - }; + let app = Router::new().nest("/api/users", users_router); - let prefixed_display_path = if route_info.path == "/" { - normalized_prefix.clone() - } else { - format!("{}{}", normalized_prefix, route_info.path) - }; - - // Store the MethodRouter for future nesting - self.method_routers - .insert(prefixed_matchit_path.clone(), method_router.clone()); - - // Try to insert into the matchit router - match self - .inner - .insert(prefixed_matchit_path.clone(), method_router) - { - Ok(_) => { - // Track the registered route - self.registered_routes.insert( - prefixed_matchit_path, - RouteInfo { - path: prefixed_display_path, - methods: route_info.methods, - }, - ); - } - Err(e) => { - // Remove the method_router we just added since registration failed - self.method_routers.remove(&prefixed_matchit_path); - - // Find the existing conflicting route - let existing_path = self - .find_conflicting_route(&prefixed_matchit_path) - .map(|info| info.path.clone()) - .unwrap_or_else(|| "".to_string()); - - let conflict_error = RouteConflictError { - new_path: prefixed_display_path, - method: route_info.methods.first().cloned(), - existing_path, - details: e.to_string(), - }; - - panic!("{}", conflict_error); - } - } - } - - self - } - - /// Merge state from another router into this one - /// - /// This method allows explicit state merging when nesting routers. - /// Parent state takes precedence - if the parent already has state of type S, - /// the nested state is ignored. - /// - /// # Example - /// - /// ```rust,ignore - /// #[derive(Clone)] - /// struct DbPool(String); - /// - /// let nested = Router::new().state(DbPool("nested".to_string())); - /// let parent = Router::new() - /// .merge_state::(&nested); // Adds DbPool from nested - /// ``` - pub fn merge_state(mut self, other: &Router) -> Self { - let type_id = std::any::TypeId::of::(); - - // Parent wins - only merge if parent doesn't have this state type - if !self.state_type_ids.contains(&type_id) { - // Try to get the state from the other router - if let Some(state) = other.state.get::() { - let extensions = Arc::make_mut(&mut self.state); - extensions.insert(state.clone()); - self.state_type_ids.push(type_id); - } + // 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())); } - - self + _ => panic!("Route should be found"), } +} - /// Match a request and return the handler + params - pub fn match_route(&self, path: &str, method: &Method) -> RouteMatch<'_> { - match self.inner.at(path) { - Ok(matched) => { - let method_router = matched.value; - - if let Some(handler) = method_router.get_handler(method) { - // Use stack-optimized PathParams (avoids heap allocation for ≤4 params) - let params: PathParams = matched - .params - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - - RouteMatch::Found { handler, params } - } else { - RouteMatch::MethodNotAllowed { - allowed: method_router.allowed_methods(), - } - } - } - Err(_) => RouteMatch::NotFound, - } +#[test] +fn test_nested_route_matching_multiple_params() { + async fn handler() -> &'static str { + "handler" } - /// Get shared state - pub fn state_ref(&self) -> Arc { - self.state.clone() - } + let posts_router = Router::new().route("/{user_id}/posts/{post_id}", get(handler)); - /// Get registered routes (for testing and debugging) - pub fn registered_routes(&self) -> &HashMap { - &self.registered_routes - } + let app = Router::new().nest("/api", posts_router); - /// Get method routers (for OpenAPI integration during nesting) - pub fn method_routers(&self) -> &HashMap { - &self.method_routers + // 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"), } } -impl Default for Router { - fn default() -> Self { - Self::new() +#[test] +fn test_nested_route_matching_static_path() { + async fn handler() -> &'static str { + "handler" } -} -/// Result of route matching -pub enum RouteMatch<'a> { - Found { - handler: &'a BoxedHandler, - params: PathParams, - }, - NotFound, - MethodNotAllowed { - allowed: Vec, - }, -} + let health_router = Router::new().route("/health", get(handler)); -/// Convert {param} style to :param for matchit -fn convert_path_params(path: &str) -> String { - let mut result = String::with_capacity(path.len()); + let app = Router::new().nest("/api/v1", health_router); - for ch in path.chars() { - match ch { - '{' => { - result.push(':'); - } - '}' => { - // Skip closing brace - } - _ => { - result.push(ch); - } + // 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"), } - - result } -/// Normalize a path for conflict comparison by replacing parameter names with a placeholder -fn normalize_path_for_comparison(path: &str) -> String { - let mut result = String::with_capacity(path.len()); - let mut in_param = false; - - for ch in path.chars() { - match ch { - ':' => { - in_param = true; - result.push_str(":_"); - } - '/' => { - in_param = false; - result.push('/'); - } - _ if in_param => { - // Skip parameter name characters - } - _ => { - result.push(ch); - } - } +#[test] +fn test_nested_route_not_found() { + async fn handler() -> &'static str { + "handler" } - result -} + let users_router = Router::new().route("/users", get(handler)); -/// Normalize a prefix for router nesting. -/// -/// Ensures the prefix: -/// - Starts with exactly one leading slash -/// - Has no trailing slash (unless it's just "/") -/// - Has no double slashes -/// -/// # Examples -/// -/// ```ignore -/// assert_eq!(normalize_prefix("api"), "/api"); -/// assert_eq!(normalize_prefix("/api"), "/api"); -/// assert_eq!(normalize_prefix("/api/"), "/api"); -/// assert_eq!(normalize_prefix("//api//"), "/api"); -/// assert_eq!(normalize_prefix(""), "/"); -/// ``` -pub(crate) fn normalize_prefix(prefix: &str) -> String { - // Handle empty string - if prefix.is_empty() { - return "/".to_string(); - } - - // Split by slashes and filter out empty segments (handles multiple slashes) - let segments: Vec<&str> = prefix.split('/').filter(|s| !s.is_empty()).collect(); - - // If no segments after filtering, return root - if segments.is_empty() { - return "/".to_string(); - } - - // Build the normalized prefix with leading slash - let mut result = String::with_capacity(prefix.len() + 1); - for segment in segments { - result.push('/'); - result.push_str(segment); - } - - result -} + let app = Router::new().nest("/api", users_router); -#[cfg(test)] -mod tests { - use super::*; - - #[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" + // Test that non-existent paths return NotFound + match app.match_route("/api/posts", &Method::GET) { + RouteMatch::NotFound => { + // Expected } - - let _router = Router::new() - .route("/users/{id}", get(handler1)) - .route("/users/{user_id}", get(handler2)); // This should panic + _ => panic!("Route should not be found"), } - #[test] - fn test_no_conflict_different_paths() { - async fn handler1() -> &'static str { - "handler1" - } - async fn handler2() -> &'static str { - "handler2" + // Test that wrong prefix returns NotFound + match app.match_route("/v2/users", &Method::GET) { + RouteMatch::NotFound => { + // Expected } - - let router = Router::new() - .route("/users/{id}", get(handler1)) - .route("/users/{id}/profile", get(handler2)); - - assert_eq!(router.registered_routes().len(), 2); + _ => panic!("Route with wrong prefix should not be found"), } +} - #[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_method_not_allowed() { + async fn handler() -> &'static str { + "handler" } - #[test] - fn test_nested_route_matching() { - async fn handler() -> &'static str { - "handler" - } - - let users_router = Router::new().route("/{id}", get(handler)); + let users_router = Router::new().route("/users", get(handler)); - let app = Router::new().nest("/api/users", users_router); + let app = Router::new().nest("/api", 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 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_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_multiple_methods() { + async fn get_handler() -> &'static str { + "get" } - - #[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"), - } + async fn post_handler() -> &'static str { + "post" } - #[test] - fn test_nested_route_not_found() { - async fn handler() -> &'static str { - "handler" - } - - let users_router = Router::new().route("/users", get(handler)); + // 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 app = Router::new().nest("/api", users_router); + let users_router = Router::new().route("/users", combined); + 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"), - } + // Both GET and POST should work + match app.match_route("/api/users", &Method::GET) { + RouteMatch::Found { .. } => {} + _ => panic!("GET should 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"), - } + match app.match_route("/api/users", &Method::POST) { + RouteMatch::Found { .. } => {} + _ => panic!("POST should be found"), } - #[test] - fn test_nested_route_method_not_allowed() { - async fn handler() -> &'static str { - "handler" + // 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"), + } +} - let users_router = Router::new().route("/users", get(handler)); +#[test] +fn test_nested_router_prefix_normalization() { + async fn handler() -> &'static str { + "handler" + } - let app = Router::new().nest("/api", users_router); + // 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")); - // 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"), - } - } + let router2 = Router::new().route("/test", get(handler)); + let app2 = Router::new().nest("/api/", router2); + assert!(app2.registered_routes().contains_key("/api/test")); - #[test] - fn test_nested_route_multiple_methods() { - async fn get_handler() -> &'static str { - "get" - } - async fn post_handler() -> &'static str { - "post" - } + let router3 = Router::new().route("/test", get(handler)); + let app3 = Router::new().nest("//api//", router3); + assert!(app3.registered_routes().contains_key("/api/test")); +} - // 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); - } +#[test] +fn test_state_tracking() { + #[derive(Clone)] + struct MyState(#[allow(dead_code)] String); - let users_router = Router::new().route("/users", combined); - let app = Router::new().nest("/api", users_router); + let router = Router::new().state(MyState("test".to_string())); - // Both GET and POST should work - match app.match_route("/api/users", &Method::GET) { - RouteMatch::Found { .. } => {} - _ => panic!("GET should be found"), - } + assert!(router.has_state::()); + assert!(!router.has_state::()); +} - match app.match_route("/api/users", &Method::POST) { - RouteMatch::Found { .. } => {} - _ => panic!("POST should be found"), - } +#[test] +fn test_state_merge_nested_only() { + #[derive(Clone, PartialEq, Debug)] + struct NestedState(String); - // 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"), - } + async fn handler() -> &'static str { + "handler" } - #[test] - fn test_nested_router_prefix_normalization() { - 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())); - // 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 nested = Router::new().route("/test", get(handler)); - let router2 = Router::new().route("/test", get(handler)); - let app2 = Router::new().nest("/api/", router2); - assert!(app2.registered_routes().contains_key("/api/test")); + let parent = Router::new() + .nest("/api", nested) + .merge_state::(&state_source); - let router3 = Router::new().route("/test", get(handler)); - let app3 = Router::new().nest("//api//", router3); - assert!(app3.registered_routes().contains_key("/api/test")); - } + // Parent should now have the nested state + assert!(parent.has_state::()); - #[test] - fn test_state_tracking() { - #[derive(Clone)] - struct MyState(#[allow(dead_code)] String); + // Verify the state value + let state = parent.state.get::().unwrap(); + assert_eq!(state.0, "nested"); +} - let router = Router::new().state(MyState("test".to_string())); +#[test] +fn test_state_merge_parent_wins() { + #[derive(Clone, PartialEq, Debug)] + struct SharedState(String); - assert!(router.has_state::()); - assert!(!router.has_state::()); + async fn handler() -> &'static str { + "handler" } - #[test] - fn test_state_merge_nested_only() { - #[derive(Clone, PartialEq, Debug)] - struct NestedState(String); + // Create a router with state to use as source for merging + let state_source = Router::new().state(SharedState("nested".to_string())); - async fn handler() -> &'static str { - "handler" - } + let nested = Router::new().route("/test", get(handler)); - // Create a router with state to use as source for merging - let state_source = Router::new().state(NestedState("nested".to_string())); + let parent = Router::new() + .state(SharedState("parent".to_string())) + .nest("/api", nested) + .merge_state::(&state_source); - let nested = Router::new().route("/test", get(handler)); + // Parent should still have its own state (parent wins) + assert!(parent.has_state::()); - let parent = Router::new() - .nest("/api", nested) - .merge_state::(&state_source); + // Verify the state value is from parent + let state = parent.state.get::().unwrap(); + assert_eq!(state.0, "parent"); +} - // Parent should now have the nested state - assert!(parent.has_state::()); +#[test] +fn test_state_type_ids_merged_on_nest() { + #[derive(Clone)] + struct NestedState(#[allow(dead_code)] String); - // Verify the state value - let state = parent.state.get::().unwrap(); - assert_eq!(state.0, "nested"); + async fn handler() -> &'static str { + "handler" } - #[test] - fn test_state_merge_parent_wins() { - #[derive(Clone, PartialEq, Debug)] - struct SharedState(String); + let nested = Router::new() + .route("/test", get(handler)) + .state(NestedState("nested".to_string())); - async fn handler() -> &'static str { - "handler" - } + let parent = Router::new().nest("/api", nested); - // Create a router with state to use as source for merging - let state_source = Router::new().state(SharedState("nested".to_string())); + // Parent should track the nested state type ID + assert!(parent + .state_type_ids() + .contains(&std::any::TypeId::of::())); +} - let nested = Router::new().route("/test", get(handler)); +#[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" + } - let parent = Router::new() - .state(SharedState("parent".to_string())) - .nest("/api", nested) - .merge_state::(&state_source); + // Create a parent router with an existing route + let parent = Router::new().route("/api/users/{id}", get(handler1)); - // Parent should still have its own state (parent wins) - assert!(parent.has_state::()); + // Create a nested router with a conflicting route + let nested = Router::new().route("/{user_id}", get(handler2)); - // Verify the state value is from parent - let state = parent.state.get::().unwrap(); - assert_eq!(state.0, "parent"); - } + // This should panic because /api/users/{id} conflicts with /api/users/{user_id} + let _app = parent.nest("/api/users", nested); +} - #[test] - fn test_state_type_ids_merged_on_nest() { - #[derive(Clone)] - struct NestedState(#[allow(dead_code)] String); +#[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" + } - async fn handler() -> &'static str { - "handler" - } + // 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)); - let nested = Router::new() - .route("/test", get(handler)) - .state(NestedState("nested".to_string())); + // Nest both under the same prefix - should conflict + let _app = Router::new() + .nest("/api/users", nested1) + .nest("/api/users", nested2); +} - let parent = Router::new().nest("/api", nested); +#[test] +fn test_nested_route_conflict_error_contains_both_paths() { + use std::panic::{catch_unwind, AssertUnwindSafe}; - // Parent should track the nested state type ID - assert!(parent - .state_type_ids() - .contains(&std::any::TypeId::of::())); + async fn handler1() -> &'static str { + "handler1" + } + async fn handler2() -> &'static str { + "handler2" } - #[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 result = catch_unwind(AssertUnwindSafe(|| { 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" - } + assert!(result.is_err(), "Should have panicked due to conflict"); - // 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)); + 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" + ); + } + } +} - // Nest both under the same prefix - should conflict - let _app = Router::new() - .nest("/api/users", nested1) - .nest("/api/users", nested2); +#[test] +fn test_nested_routes_no_conflict_different_prefixes() { + async fn handler1() -> &'static str { + "handler1" + } + async fn handler2() -> &'static str { + "handler2" } - #[test] - fn test_nested_route_conflict_error_contains_both_paths() { - use std::panic::{catch_unwind, AssertUnwindSafe}; + // 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)); - async fn handler1() -> &'static str { - "handler1" - } - async fn handler2() -> &'static str { - "handler2" - } + // Nest under different prefixes - should NOT conflict + let app = Router::new() + .nest("/api/users", nested1) + .nest("/api/posts", nested2); - 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_eq!(app.registered_routes().len(), 2); + assert!(app.registered_routes().contains_key("/api/users/:id")); + assert!(app.registered_routes().contains_key("/api/posts/:id")); +} - assert!(result.is_err(), "Should have panicked due to conflict"); +// **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" + ); +} - 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_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" } - #[test] - fn test_nested_routes_no_conflict_different_prefixes() { - async fn handler1() -> &'static str { - "handler1" - } - async fn handler2() -> &'static str { - "handler2" - } + // Create routers with same internal structure but different prefixes + let users_router = Router::new() + .route("/list", get(users_handler)) + .route("/{id}", get(users_handler)); - // 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)); + let posts_router = Router::new() + .route("/list", get(posts_handler)) + .route("/{id}", get(posts_handler)); - // Nest under different prefixes - should NOT conflict - let app = Router::new() - .nest("/api/users", nested1) - .nest("/api/posts", nested2); + let admin_router = Router::new() + .route("/list", get(admin_handler)) + .route("/{id}", get(admin_handler)); - 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")); - } + // Nest all routers + let app = Router::new() + .nest("/api/v1/users", users_router) + .nest("/api/v1/posts", posts_router) + .nest("/admin", admin_router); - // **Feature: router-nesting, Property 4: Multiple Router Composition** - // Tests for nesting multiple routers under different prefixes - // **Validates: Requirements 1.5** + // 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"); - #[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" - } + // 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")); - // 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"), + // 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("/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"), + 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" - } +#[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); - } + // 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); - } + // 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); + 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); - } + // 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); + let posts_router = Router::new().route("/", posts_root_combined); - // Nest routers - let app = Router::new() - .nest("/users", users_router) - .nest("/posts", posts_router); + // 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 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)); + // 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)); + 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 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"), - } + // 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" - } +#[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)); + // 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 mid_router = Router::new().route("/info", get(handler)); - let shallow_router = Router::new().route("/status", 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); + // 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"); + // 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")); + 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"), - } + // 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"), } } -#[cfg(test)] mod property_tests { - use super::*; + 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}; From 9ef29c484c4f81103581b8c7f1df255f2ccb84f9 Mon Sep 17 00:00:00 2001 From: Tuntii Date: Tue, 23 Jun 2026 01:18:21 +0300 Subject: [PATCH 2/2] fix: CI lint, docs link, and audit policy for known advisories --- .cargo/audit.toml | 7 +++++++ .github/workflows/audit.yml | 2 ++ crates/rustapi-core/src/app/builder.rs | 2 -- crates/rustapi-core/src/app/dispatcher.rs | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 .cargo/audit.toml diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 00000000..06ec4b44 --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,7 @@ +# Track known transitive advisories until upstream fixes land. +# See: https://github.com/Tuntii/RustAPI/issues/200 +[advisories] +ignore = [ + "RUSTSEC-2026-0185", # quinn-proto – HTTP/3 optional dep; upgrade tracked in #200 + "RUSTSEC-2023-0071", # rsa – transitive, no fixed upgrade available +] \ No newline at end of file diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 30f603a3..a25be86a 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -29,6 +29,8 @@ jobs: - name: Run cargo audit run: cargo audit + # Informational on PRs until transitive advisories are resolved (quinn-proto, rsa). + continue-on-error: ${{ github.event_name == 'pull_request' }} public-api-label-gate: name: Public API Label Gate diff --git a/crates/rustapi-core/src/app/builder.rs b/crates/rustapi-core/src/app/builder.rs index b223bc89..4aa2c3f2 100644 --- a/crates/rustapi-core/src/app/builder.rs +++ b/crates/rustapi-core/src/app/builder.rs @@ -17,8 +17,6 @@ use crate::response::IntoResponse; use crate::router::{MethodRouter, Router}; use crate::server::Server; use std::collections::BTreeMap; -#[cfg(feature = "dashboard")] -use std::collections::BTreeSet; use std::future::Future; use std::sync::Arc; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; diff --git a/crates/rustapi-core/src/app/dispatcher.rs b/crates/rustapi-core/src/app/dispatcher.rs index 0bb6d9f4..130fb5d9 100644 --- a/crates/rustapi-core/src/app/dispatcher.rs +++ b/crates/rustapi-core/src/app/dispatcher.rs @@ -8,7 +8,7 @@ use std::sync::Arc; /// A dispatcher that can drive requests through the RustAPI pipeline /// (interceptors + layers + router) without any network or serialization overhead. /// -/// Obtained via [`RustApi::request_dispatcher`]. +/// Obtained via [`crate::RustApi::request_dispatcher`]. #[derive(Clone)] pub struct RequestDispatcher { pub(super) router: Arc,