From 2380ab5fbf11473dd48c284ab793f29da87cd6a6 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 8 Mar 2026 20:00:35 +0300 Subject: [PATCH 1/7] Add health endpoints, production defaults, perf Introduce built-in health endpoints and a production baseline preset, plus a synthetic in-process performance snapshot and CI wiring. Changes include: add HealthEndpointConfig, HealthResponse and health helper handlers; RustApi methods to enable health_endpoints(), with_health_check(), and production_defaults()/production_defaults_with_config(); automatically register /health, /ready and /live when configured; tests for health endpoints; a new perf_snapshot example and CI step to run and upload its output; README, CHANGELOG and RELEASES updates to document production defaults and benchmark guidance; new .env for local examples; API exports extended (oauth2/session/job/session stores and helpers); new session module and multiple cookbook/docs/recipes additions; and Cargo.lock updates for new dependencies. Overall this adds production probe ergonomics and a reproducible perf snapshot to the project. --- .env | 8 + .github/workflows/benchmark.yml | 7 +- .gitignore | 6 + CHANGELOG.md | 2 + Cargo.lock | 36 +- README.md | 50 +- RELEASES.md | 2 + api/public/rustapi-rs.all-features.txt | 63 ++ api/public/rustapi-rs.default.txt | 1 + crates/rustapi-core/examples/perf_snapshot.rs | 347 +++++++ crates/rustapi-core/src/app.rs | 216 ++++ crates/rustapi-core/src/extract.rs | 44 + crates/rustapi-core/src/health.rs | 173 ++++ crates/rustapi-core/src/lib.rs | 9 +- crates/rustapi-core/tests/health_endpoints.rs | 133 +++ .../rustapi-core/tests/production_defaults.rs | 125 +++ crates/rustapi-extras/Cargo.toml | 5 +- crates/rustapi-extras/src/lib.rs | 12 + crates/rustapi-extras/src/oauth2/tokens.rs | 29 +- crates/rustapi-extras/src/session/mod.rs | 967 ++++++++++++++++++ crates/rustapi-rs/Cargo.toml | 14 + crates/rustapi-rs/examples/README.md | 94 ++ crates/rustapi-rs/examples/auth_api.rs | 111 ++ crates/rustapi-rs/examples/full_crud_api.rs | 112 ++ crates/rustapi-rs/examples/jobs_api.rs | 136 +++ crates/rustapi-rs/examples/streaming_api.rs | 48 + crates/rustapi-rs/src/lib.rs | 101 +- docs/GETTING_STARTED.md | 37 + docs/PERFORMANCE_BENCHMARKS.md | 130 +++ docs/README.md | 22 +- docs/cookbook/src/SUMMARY.md | 11 + docs/cookbook/src/concepts/performance.md | 60 +- docs/cookbook/src/learning/curriculum.md | 2 +- docs/cookbook/src/recipes/README.md | 9 + docs/cookbook/src/recipes/actix_migration.md | 378 +++++++ docs/cookbook/src/recipes/axum_migration.md | 361 +++++++ .../cookbook/src/recipes/custom_extractors.md | 169 +++ docs/cookbook/src/recipes/db_integration.md | 82 +- docs/cookbook/src/recipes/deployment.md | 103 +- docs/cookbook/src/recipes/error_handling.md | 220 ++++ .../cookbook/src/recipes/graceful_shutdown.md | 99 +- .../src/recipes/middleware_debugging.md | 179 ++++ docs/cookbook/src/recipes/oauth2_client.md | 56 +- docs/cookbook/src/recipes/observability.md | 177 ++++ .../src/recipes/oidc_oauth2_production.md | 178 ++++ docs/cookbook/src/recipes/session_auth.md | 170 +++ docs/cookbook/src/reference/README.md | 4 +- .../src/reference/macro_attributes.md | 270 +++++ tasks.md | 100 ++ 49 files changed, 5553 insertions(+), 115 deletions(-) create mode 100644 .env create mode 100644 crates/rustapi-core/examples/perf_snapshot.rs create mode 100644 crates/rustapi-core/tests/health_endpoints.rs create mode 100644 crates/rustapi-core/tests/production_defaults.rs create mode 100644 crates/rustapi-extras/src/session/mod.rs create mode 100644 crates/rustapi-rs/examples/README.md create mode 100644 crates/rustapi-rs/examples/auth_api.rs create mode 100644 crates/rustapi-rs/examples/full_crud_api.rs create mode 100644 crates/rustapi-rs/examples/jobs_api.rs create mode 100644 crates/rustapi-rs/examples/streaming_api.rs create mode 100644 docs/PERFORMANCE_BENCHMARKS.md create mode 100644 docs/cookbook/src/recipes/actix_migration.md create mode 100644 docs/cookbook/src/recipes/axum_migration.md create mode 100644 docs/cookbook/src/recipes/custom_extractors.md create mode 100644 docs/cookbook/src/recipes/error_handling.md create mode 100644 docs/cookbook/src/recipes/middleware_debugging.md create mode 100644 docs/cookbook/src/recipes/observability.md create mode 100644 docs/cookbook/src/recipes/oidc_oauth2_production.md create mode 100644 docs/cookbook/src/recipes/session_auth.md create mode 100644 docs/cookbook/src/reference/macro_attributes.md create mode 100644 tasks.md diff --git a/.env b/.env new file mode 100644 index 00000000..c06a3232 --- /dev/null +++ b/.env @@ -0,0 +1,8 @@ +# Placeholder environment variables for local documentation/examples +# 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 diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 75efca07..26b8aea1 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -28,8 +28,13 @@ jobs: - name: Run Benchmarks run: cargo bench --workspace | tee benchmark_results.txt + - name: Run Performance Snapshot + run: cargo run -p rustapi-core --example perf_snapshot --release | tee perf_snapshot.txt + - name: Upload Benchmark Results uses: actions/upload-artifact@v4 with: name: benchmark-results - path: benchmark_results.txt + path: | + benchmark_results.txt + perf_snapshot.txt diff --git a/.gitignore b/.gitignore index 65734dbd..4cafb6ee 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,9 @@ assets/b9c93c1cd427d8f50e68dbd11ed2b000.jpg docs/cookbook/book/ build_rs_cov.profraw +answer.md +docs/PRODUCTION_BASELINE.md +docs/PRODUCTION_CHECKLIST.md +/.github/instructions +/.github/prompts +/.github/skills diff --git a/CHANGELOG.md b/CHANGELOG.md index a41b0f5d..5b32e0af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,6 +140,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 This release delivers a **12x performance improvement**, bringing RustAPI from ~8K req/s to **~92K req/s**. +> Note: the numbers below are preserved as a **historical release snapshot**. Current benchmark methodology and canonical public performance claims are maintained in `docs/PERFORMANCE_BENCHMARKS.md`. + #### Benchmark Results | Framework | Requests/sec | Latency (avg) | diff --git a/Cargo.lock b/Cargo.lock index 93089dbb..b3a1cfb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1757,6 +1757,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2873,6 +2882,28 @@ dependencies = [ "url", ] +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures-util", + "itertools 0.13.0", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3156,6 +3187,7 @@ dependencies = [ "proptest", "r2d2", "rand 0.8.5", + "redis 0.27.6", "reqwest", "rustapi-core", "rustapi-openapi", @@ -3193,7 +3225,7 @@ dependencies = [ "chrono", "futures-util", "proptest", - "redis", + "redis 0.24.0", "serde", "serde_json", "sqlx", @@ -3234,9 +3266,11 @@ version = "0.1.389" dependencies = [ "async-trait", "doc-comment", + "futures-util", "rustapi-core", "rustapi-extras", "rustapi-grpc", + "rustapi-jobs", "rustapi-macros", "rustapi-openapi", "rustapi-toon", diff --git a/README.md b/README.md index 55c0a35d..1aab8843 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ RustAPI ships circuit breaker and retry middleware as first-class features, not - **Retry** with exponential backoff - **Rate Limiting** (IP-based, per-route) - **Body Limit** with configurable max size (default 1 MB) +- **Health Probes** via `.health_endpoints()` for `/health`, `/ready`, and `/live` ### Environment-Aware Error Masking @@ -121,7 +122,7 @@ Run HTTP/1.1 (TCP) and HTTP/3 (QUIC/UDP) simultaneously on the same server. Enab | Feature | RustAPI | Actix-web | Axum | FastAPI (Python) | |:--------|:-------:|:---------:|:----:|:----------------:| -| Performance | ~92k req/s | ~105k | ~100k | ~12k | +| Performance | See benchmark source | Workload-dependent | Workload-dependent | Workload-dependent | | Ergonomics | High | Low | Medium | High | | AI/LLM native format (TOON) | Yes | No | No | No | | Request replay / time-travel debug | Built-in | No | No | 3rd-party | @@ -132,6 +133,8 @@ Run HTTP/1.1 (TCP) and HTTP/3 (QUIC/UDP) simultaneously on the same server. Enab | Background jobs | Built-in | 3rd-party | 3rd-party | 3rd-party | | API stability model | Facade + CI contract | Direct | Direct | Stable | +Current benchmark methodology and canonical published performance claims live in [`docs/PERFORMANCE_BENCHMARKS.md`](docs/PERFORMANCE_BENCHMARKS.md). Historical point-in-time numbers in older release notes should not be treated as the current baseline unless they are linked from that document. + ## Quick Start ```rust @@ -146,13 +149,52 @@ async fn hello(Path(name): Path) -> Json { } #[rustapi_rs::main] -async fn main() { - RustApi::auto().run("127.0.0.1:8080").await +async fn main() -> std::result::Result<(), Box> { + RustApi::auto().run("127.0.0.1:8080").await } ``` `RustApi::auto()` collects all macro-annotated handlers, generates OpenAPI documentation (served at `/docs`), and starts a multi-threaded tokio runtime. +For production deployments, you can enable standard probe endpoints without writing handlers manually: + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { + let health = HealthCheckBuilder::new(true) + .add_check("database", || async { HealthStatus::healthy() }) + .build(); + + RustApi::auto() + .with_health_check(health) + .run("127.0.0.1:8080") + .await +} +``` + +This registers: +- `/health` — aggregate dependency health +- `/ready` — readiness probe (`503` when dependencies are unhealthy) +- `/live` — lightweight liveness probe + +Or use a single production baseline preset: + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { + RustApi::auto() + .production_defaults("users-api") + .run("127.0.0.1:8080") + .await +} +``` + +`production_defaults()` enables request IDs, tracing spans, and standard probe endpoints in one call. + You can shorten the macro prefix by renaming the crate: ```toml @@ -202,6 +244,8 @@ Detailed architecture, recipes, and guides are in the [Cookbook](docs/cookbook/s - [System Architecture](docs/cookbook/src/architecture/system_overview.md) - [Performance Benchmarks](docs/cookbook/src/concepts/performance.md) - [gRPC Integration Guide](docs/cookbook/src/crates/rustapi_grpc.md) +- [Recommended Production Baseline](docs/PRODUCTION_BASELINE.md) +- [Production Checklist](docs/PRODUCTION_CHECKLIST.md) - [Examples](crates/rustapi-rs/examples/) --- diff --git a/RELEASES.md b/RELEASES.md index 79410d0c..f60879a1 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -3,6 +3,8 @@ **Release Date**: February 26, 2026 **Full Changelog**: https://github.com/Tuntii/RustAPI/compare/v0.1.335...v0.1.397 +**Benchmark Source of Truth**: Current benchmark methodology and canonical performance claims live in `docs/PERFORMANCE_BENCHMARKS.md`. Historical release-specific benchmark notes should be treated as point-in-time snapshots unless they are linked from that document. + --- ## 🎯 Highlights diff --git a/api/public/rustapi-rs.all-features.txt b/api/public/rustapi-rs.all-features.txt index 42d89b56..01d19d78 100644 --- a/api/public/rustapi-rs.all-features.txt +++ b/api/public/rustapi-rs.all-features.txt @@ -17,6 +17,7 @@ pub use rustapi_rs::ConfigError pub use rustapi_rs::Cookies pub use rustapi_rs::CorsLayer pub use rustapi_rs::Created +pub use rustapi_rs::CsrfState pub use rustapi_rs::Environment pub use rustapi_rs::Extension pub use rustapi_rs::ExtrasEnvironment @@ -36,15 +37,27 @@ pub use rustapi_rs::JwtError pub use rustapi_rs::JwtLayer pub use rustapi_rs::JwtValidation pub use rustapi_rs::KeepAlive +pub use rustapi_rs::MemorySessionStore pub use rustapi_rs::MethodRouter pub use rustapi_rs::Multipart pub use rustapi_rs::MultipartConfig pub use rustapi_rs::MultipartField pub use rustapi_rs::NoContent +pub use rustapi_rs::OAuth2Client +pub use rustapi_rs::OAuth2Config +pub use rustapi_rs::Job +pub use rustapi_rs::JobBackend +pub use rustapi_rs::JobContext +pub use rustapi_rs::JobError +pub use rustapi_rs::JobQueue +pub use rustapi_rs::JobRequest +pub use rustapi_rs::PkceVerifier pub use rustapi_rs::Path +pub use rustapi_rs::Provider pub use rustapi_rs::Query pub use rustapi_rs::RateLimitLayer pub use rustapi_rs::Redirect +pub use rustapi_rs::RedisSessionStore pub use rustapi_rs::Request pub use rustapi_rs::RequestId pub use rustapi_rs::RequestIdLayer @@ -57,6 +70,12 @@ pub use rustapi_rs::RouteMatch pub use rustapi_rs::Router pub use rustapi_rs::RustApi pub use rustapi_rs::RustApiConfig +pub use rustapi_rs::Session +pub use rustapi_rs::SessionConfig +pub use rustapi_rs::SessionError +pub use rustapi_rs::SessionLayer +pub use rustapi_rs::SessionRecord +pub use rustapi_rs::SessionStore pub use rustapi_rs::SqlxErrorExt pub use rustapi_rs::Sse pub use rustapi_rs::SseEvent @@ -65,6 +84,10 @@ pub use rustapi_rs::StaticFile pub use rustapi_rs::StaticFileConfig pub use rustapi_rs::StatusCode pub use rustapi_rs::StreamBody +pub use rustapi_rs::EnqueueOptions +pub use rustapi_rs::InMemoryBackend +pub use rustapi_rs::TokenError +pub use rustapi_rs::TokenResponse pub use rustapi_rs::TracingLayer pub use rustapi_rs::Typed pub use rustapi_rs::TypedPath @@ -73,6 +96,8 @@ pub use rustapi_rs::Validatable pub use rustapi_rs::ValidatedClaims pub use rustapi_rs::ValidatedJson pub use rustapi_rs::WithStatus +pub use rustapi_rs::AuthorizationRequest +pub use rustapi_rs::sse_from_iter pub use rustapi_rs::api_key pub use rustapi_rs::cache pub use rustapi_rs::circuit_breaker @@ -95,6 +120,7 @@ pub use rustapi_rs::load_dotenv pub use rustapi_rs::load_dotenv_from pub use rustapi_rs::logging pub use rustapi_rs::otel +pub use rustapi_rs::oauth2 pub use rustapi_rs::patch pub use rustapi_rs::patch_route pub use rustapi_rs::post @@ -109,6 +135,7 @@ pub use rustapi_rs::route pub use rustapi_rs::sanitization pub use rustapi_rs::security_headers pub use rustapi_rs::serve_dir +pub use rustapi_rs::session pub use rustapi_rs::sse_response pub use rustapi_rs::structured_logging pub use rustapi_rs::timeout @@ -228,6 +255,16 @@ pub mod rustapi_rs::extras::logging pub use rustapi_rs::extras::logging::logging pub mod rustapi_rs::extras::otel pub use rustapi_rs::extras::otel::otel +pub mod rustapi_rs::extras::oauth2 +pub use rustapi_rs::extras::oauth2::AuthorizationRequest +pub use rustapi_rs::extras::oauth2::CsrfState +pub use rustapi_rs::extras::oauth2::OAuth2Client +pub use rustapi_rs::extras::oauth2::OAuth2Config +pub use rustapi_rs::extras::oauth2::PkceVerifier +pub use rustapi_rs::extras::oauth2::Provider +pub use rustapi_rs::extras::oauth2::TokenError +pub use rustapi_rs::extras::oauth2::TokenResponse +pub use rustapi_rs::extras::oauth2::oauth2 pub mod rustapi_rs::extras::rate_limit pub use rustapi_rs::extras::rate_limit::RateLimitLayer pub use rustapi_rs::extras::rate_limit::rate_limit @@ -239,6 +276,16 @@ pub mod rustapi_rs::extras::sanitization pub use rustapi_rs::extras::sanitization::sanitization pub mod rustapi_rs::extras::security_headers pub use rustapi_rs::extras::security_headers::security_headers +pub mod rustapi_rs::extras::session +pub use rustapi_rs::extras::session::MemorySessionStore +pub use rustapi_rs::extras::session::RedisSessionStore +pub use rustapi_rs::extras::session::Session +pub use rustapi_rs::extras::session::SessionConfig +pub use rustapi_rs::extras::session::SessionError +pub use rustapi_rs::extras::session::SessionLayer +pub use rustapi_rs::extras::session::SessionRecord +pub use rustapi_rs::extras::session::SessionStore +pub use rustapi_rs::extras::session::session pub mod rustapi_rs::extras::sqlx pub use rustapi_rs::extras::sqlx::SqlxErrorExt pub use rustapi_rs::extras::sqlx::convert_sqlx_error @@ -269,6 +316,7 @@ pub use rustapi_rs::prelude::ContextBuilder pub use rustapi_rs::prelude::Cookies pub use rustapi_rs::prelude::CorsLayer pub use rustapi_rs::prelude::Created +pub use rustapi_rs::prelude::CsrfState pub use rustapi_rs::prelude::Deserialize pub use rustapi_rs::prelude::Deserialize pub use rustapi_rs::prelude::Extension @@ -283,17 +331,23 @@ pub use rustapi_rs::prelude::JwtLayer pub use rustapi_rs::prelude::JwtValidation pub use rustapi_rs::prelude::KeepAlive pub use rustapi_rs::prelude::LlmResponse +pub use rustapi_rs::prelude::MemorySessionStore pub use rustapi_rs::prelude::Message pub use rustapi_rs::prelude::Multipart pub use rustapi_rs::prelude::MultipartConfig pub use rustapi_rs::prelude::MultipartField pub use rustapi_rs::prelude::Negotiate pub use rustapi_rs::prelude::NoContent +pub use rustapi_rs::prelude::OAuth2Client +pub use rustapi_rs::prelude::OAuth2Config pub use rustapi_rs::prelude::OutputFormat pub use rustapi_rs::prelude::Path +pub use rustapi_rs::prelude::PkceVerifier +pub use rustapi_rs::prelude::Provider pub use rustapi_rs::prelude::Query pub use rustapi_rs::prelude::RateLimitLayer pub use rustapi_rs::prelude::Redirect +pub use rustapi_rs::prelude::RedisSessionStore pub use rustapi_rs::prelude::Request pub use rustapi_rs::prelude::RequestId pub use rustapi_rs::prelude::RequestIdLayer @@ -304,6 +358,12 @@ pub use rustapi_rs::prelude::Router pub use rustapi_rs::prelude::RustApi pub use rustapi_rs::prelude::RustApiConfig pub use rustapi_rs::prelude::Schema +pub use rustapi_rs::prelude::Session +pub use rustapi_rs::prelude::SessionConfig +pub use rustapi_rs::prelude::SessionError +pub use rustapi_rs::prelude::SessionLayer +pub use rustapi_rs::prelude::SessionRecord +pub use rustapi_rs::prelude::SessionStore pub use rustapi_rs::prelude::Serialize pub use rustapi_rs::prelude::Serialize pub use rustapi_rs::prelude::SqlxErrorExt @@ -332,6 +392,9 @@ pub use rustapi_rs::prelude::View pub use rustapi_rs::prelude::WebSocket pub use rustapi_rs::prelude::WebSocketStream pub use rustapi_rs::prelude::WithStatus +pub use rustapi_rs::prelude::AuthorizationRequest +pub use rustapi_rs::prelude::TokenError +pub use rustapi_rs::prelude::TokenResponse pub use rustapi_rs::prelude::convert_sqlx_error pub use rustapi_rs::prelude::create_token pub use rustapi_rs::prelude::debug diff --git a/api/public/rustapi-rs.default.txt b/api/public/rustapi-rs.default.txt index 35d17c60..1a99ec04 100644 --- a/api/public/rustapi-rs.default.txt +++ b/api/public/rustapi-rs.default.txt @@ -55,6 +55,7 @@ pub use rustapi_rs::UploadedFile pub use rustapi_rs::Validatable pub use rustapi_rs::ValidatedJson pub use rustapi_rs::WithStatus +pub use rustapi_rs::sse_from_iter pub use rustapi_rs::collect_auto_routes pub use rustapi_rs::delete pub use rustapi_rs::delete_route diff --git a/crates/rustapi-core/examples/perf_snapshot.rs b/crates/rustapi-core/examples/perf_snapshot.rs new file mode 100644 index 00000000..26481f29 --- /dev/null +++ b/crates/rustapi-core/examples/perf_snapshot.rs @@ -0,0 +1,347 @@ +use bytes::Bytes; +use http::{Extensions, Method, StatusCode}; +use rustapi_core::interceptor::{RequestInterceptor, ResponseInterceptor}; +use rustapi_core::middleware::{BoxedNext, MiddlewareLayer}; +use rustapi_core::{get, BodyVariant, IntoResponse, PathParams, Request, Response, RouteMatch, RustApi}; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Instant; + +const DEFAULT_WARMUP_ITERS: usize = 2_000; +const DEFAULT_SAMPLE_ITERS: usize = 20_000; + +#[derive(Clone)] +struct NoopRequestInterceptor; + +impl RequestInterceptor for NoopRequestInterceptor { + fn intercept(&self, request: Request) -> Request { + request + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[derive(Clone)] +struct NoopResponseInterceptor; + +impl ResponseInterceptor for NoopResponseInterceptor { + fn intercept(&self, response: Response) -> Response { + response + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[derive(Clone)] +struct NoopMiddleware; + +impl MiddlewareLayer for NoopMiddleware { + fn call( + &self, + req: Request, + next: BoxedNext, + ) -> Pin + Send + 'static>> { + Box::pin(async move { next(req).await }) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[derive(Clone)] +struct Scenario { + name: &'static str, + path_kind: &'static str, + features: &'static str, + router: Arc, + layers: Arc, + interceptors: Arc, +} + +#[derive(Debug, Clone)] +struct ScenarioResult { + name: &'static str, + path_kind: &'static str, + features: &'static str, + throughput_req_s: f64, + p50_us: f64, + p95_us: f64, + p99_us: f64, + mean_us: f64, +} + +fn scenario(name: &'static str, path_kind: &'static str, features: &'static str, app: RustApi) -> Scenario { + let layers = app.layers().clone(); + let interceptors = app.interceptors().clone(); + let router = app.into_router(); + + Scenario { + name, + path_kind, + features, + router: Arc::new(router), + layers: Arc::new(layers), + interceptors: Arc::new(interceptors), + } +} + +fn build_request(state: Arc) -> Request { + let req = http::Request::builder() + .method(Method::GET) + .uri("/hello") + .body(()) + .expect("request build should succeed"); + let (parts, _) = req.into_parts(); + + Request::new( + parts, + BodyVariant::Buffered(Bytes::new()), + state, + PathParams::new(), + ) +} + +async fn route_request_direct( + router: &rustapi_core::Router, + request: Request, + path: &str, + method: &Method, +) -> Response { + match router.match_route(path, method) { + RouteMatch::Found { handler, .. } => { + handler(request).await + } + RouteMatch::NotFound => rustapi_core::ApiError::not_found("Not found").into_response(), + RouteMatch::MethodNotAllowed { allowed } => { + let allowed_str: Vec<&str> = allowed.iter().map(|m| m.as_str()).collect(); + let mut response = rustapi_core::ApiError::new( + StatusCode::METHOD_NOT_ALLOWED, + "method_not_allowed", + "Method not allowed", + ) + .into_response(); + response.headers_mut().insert( + http::header::ALLOW, + allowed_str.join(", ").parse().expect("allow header should parse"), + ); + response + } + } +} + +async fn execute_scenario_request(scenario: &Scenario) -> Response { + let method = Method::GET; + let path = "/hello"; + let request = build_request(scenario.router.state_ref()); + + if scenario.layers.is_empty() && scenario.interceptors.is_empty() { + route_request_direct(&scenario.router, request, path, &method).await + } else if scenario.layers.is_empty() { + let request = scenario.interceptors.intercept_request(request); + let response = route_request_direct(&scenario.router, request, path, &method).await; + scenario.interceptors.intercept_response(response) + } else { + let request = scenario.interceptors.intercept_request(request); + let router = scenario.router.clone(); + let path = path.to_string(); + let method = method.clone(); + + let final_handler: BoxedNext = Arc::new(move |req: Request| { + let router = router.clone(); + let path = path.clone(); + let method = method.clone(); + Box::pin(async move { route_request_direct(&router, req, &path, &method).await }) + as Pin + Send + 'static>> + }); + + let response = scenario.layers.execute(request, final_handler).await; + scenario.interceptors.intercept_response(response) + } +} + +async fn measure_scenario( + scenario: &Scenario, + warmup_iters: usize, + sample_iters: usize, +) -> ScenarioResult { + for _ in 0..warmup_iters { + let response = execute_scenario_request(scenario).await; + std::hint::black_box(response.status()); + } + + let mut latencies_ns = Vec::with_capacity(sample_iters); + let wall_clock_start = Instant::now(); + + for _ in 0..sample_iters { + let request_start = Instant::now(); + let response = execute_scenario_request(scenario).await; + let elapsed = request_start.elapsed(); + + assert_eq!(response.status(), StatusCode::OK, "benchmark scenario must stay healthy"); + + latencies_ns.push(elapsed.as_nanos() as u64); + std::hint::black_box(response.status()); + } + + let wall_clock_elapsed = wall_clock_start.elapsed(); + latencies_ns.sort_unstable(); + + let total_ns: u128 = latencies_ns.iter().map(|&v| v as u128).sum(); + let mean_ns = total_ns as f64 / sample_iters as f64; + + ScenarioResult { + name: scenario.name, + path_kind: scenario.path_kind, + features: scenario.features, + throughput_req_s: sample_iters as f64 / wall_clock_elapsed.as_secs_f64(), + p50_us: percentile_us(&latencies_ns, 50.0), + p95_us: percentile_us(&latencies_ns, 95.0), + p99_us: percentile_us(&latencies_ns, 99.0), + mean_us: mean_ns / 1_000.0, + } +} + +fn percentile_us(sorted_latencies_ns: &[u64], percentile: f64) -> f64 { + if sorted_latencies_ns.is_empty() { + return 0.0; + } + + let max_index = sorted_latencies_ns.len() - 1; + let rank = ((percentile / 100.0) * max_index as f64).round() as usize; + sorted_latencies_ns[rank.min(max_index)] as f64 / 1_000.0 +} + +fn parse_env_usize(name: &str, default: usize) -> usize { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(default) +} + +fn print_results(results: &[ScenarioResult]) { + println!("# RustAPI Performance Snapshot"); + println!(); + println!("Synthetic in-process request pipeline benchmark."); + println!(); + println!( + "| Scenario | Execution path | Features | Req/s | Mean (µs) | p50 (µs) | p95 (µs) | p99 (µs) |" + ); + println!( + "|---|---|---|---:|---:|---:|---:|---:|" + ); + + for result in results { + println!( + "| {} | {} | {} | {:.0} | {:.2} | {:.2} | {:.2} | {:.2} |", + result.name, + result.path_kind, + result.features, + result.throughput_req_s, + result.mean_us, + result.p50_us, + result.p95_us, + result.p99_us, + ); + } + + println!(); + if let Some(baseline) = results.iter().find(|result| result.name == "baseline") { + println!("## Relative overhead vs baseline"); + println!(); + println!("| Scenario | Req/s delta | p99 delta |", + ); + println!("|---|---:|---:|"); + for result in results { + let req_delta = ((result.throughput_req_s / baseline.throughput_req_s) - 1.0) * 100.0; + let p99_delta = ((result.p99_us / baseline.p99_us) - 1.0) * 100.0; + println!( + "| {} | {:+.2}% | {:+.2}% |", + result.name, + req_delta, + p99_delta, + ); + } + } +} + +async fn hello() -> &'static str { + "ok" +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let warmup_iters = parse_env_usize("RUSTAPI_PERF_WARMUP", DEFAULT_WARMUP_ITERS); + let sample_iters = parse_env_usize("RUSTAPI_PERF_ITERS", DEFAULT_SAMPLE_ITERS); + + let scenarios = vec![ + scenario( + "baseline", + "ultra fast", + "no middleware, no interceptors", + RustApi::new().route("/hello", get(hello)), + ), + scenario( + "request_interceptor", + "fast", + "1 request interceptor", + RustApi::new() + .request_interceptor(NoopRequestInterceptor) + .route("/hello", get(hello)), + ), + scenario( + "request_response_interceptors", + "fast", + "1 request + 1 response interceptor", + RustApi::new() + .request_interceptor(NoopRequestInterceptor) + .response_interceptor(NoopResponseInterceptor) + .route("/hello", get(hello)), + ), + scenario( + "middleware_only", + "full", + "1 middleware layer", + RustApi::new().layer(NoopMiddleware).route("/hello", get(hello)), + ), + scenario( + "full_stack_minimal", + "full", + "1 middleware + 1 request + 1 response interceptor", + RustApi::new() + .layer(NoopMiddleware) + .request_interceptor(NoopRequestInterceptor) + .response_interceptor(NoopResponseInterceptor) + .route("/hello", get(hello)), + ), + scenario( + "request_id_layer", + "full", + "RequestIdLayer", + RustApi::new() + .layer(rustapi_core::RequestIdLayer::new()) + .route("/hello", get(hello)), + ), + ]; + + println!("Warmup iterations: {}", warmup_iters); + println!("Measured iterations: {}", sample_iters); + println!(); + + let mut results = Vec::with_capacity(scenarios.len()); + for scenario in &scenarios { + println!("Running scenario: {}", scenario.name); + results.push(measure_scenario(scenario, warmup_iters, sample_iters).await); + } + + println!(); + print_results(&results); + + Ok(()) +} \ No newline at end of file diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index 7e9a5868..cd10ab78 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -38,9 +38,80 @@ pub struct RustApi { hot_reload: bool, #[cfg(feature = "http3")] http3_config: Option, + health_check: Option, + health_endpoint_config: Option, status_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 { @@ -68,6 +139,8 @@ impl RustApi { hot_reload: false, #[cfg(feature = "http3")] http3_config: None, + health_check: None, + health_endpoint_config: None, status_config: None, } } @@ -981,6 +1054,95 @@ impl RustApi { self } + /// Enable built-in `/health`, `/ready`, and `/live` endpoints with default paths. + /// + /// The default health check includes a lightweight `self` probe so the + /// endpoints are immediately useful even before dependency checks are added. + pub fn health_endpoints(mut self) -> Self { + self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default()); + if self.health_check.is_none() { + self.health_check = Some(crate::health::HealthCheckBuilder::default().build()); + } + self + } + + /// Enable built-in health endpoints with custom paths. + pub fn health_endpoints_with_config( + mut self, + config: crate::health::HealthEndpointConfig, + ) -> Self { + self.health_endpoint_config = Some(config); + if self.health_check.is_none() { + self.health_check = Some(crate::health::HealthCheckBuilder::default().build()); + } + self + } + + /// Register a custom health check and enable built-in health endpoints. + /// + /// The configured check is used by `/health` and `/ready`, while `/live` + /// remains a lightweight process-level probe. + pub fn with_health_check(mut self, health_check: crate::health::HealthCheck) -> Self { + self.health_check = Some(health_check); + if self.health_endpoint_config.is_none() { + self.health_endpoint_config = Some(crate::health::HealthEndpointConfig::default()); + } + self + } + + /// Apply a one-call production baseline preset. + /// + /// This enables: + /// - `RequestIdLayer` + /// - `TracingLayer` with `service` and `environment` fields + /// - built-in `/health`, `/ready`, and `/live` probes + pub fn production_defaults(self, service_name: impl Into) -> Self { + self.production_defaults_with_config(ProductionDefaultsConfig::new(service_name)) + } + + /// Apply the production baseline preset with custom configuration. + pub fn production_defaults_with_config(mut self, config: ProductionDefaultsConfig) -> Self { + if config.enable_request_id { + self = self.layer(crate::middleware::RequestIdLayer::new()); + } + + if config.enable_tracing { + let mut tracing_layer = + crate::middleware::TracingLayer::with_level(config.tracing_level) + .with_field("service", config.service_name.clone()) + .with_field( + "environment", + crate::error::get_environment().to_string(), + ); + + if let Some(version) = &config.version { + tracing_layer = tracing_layer.with_field("version", version.clone()); + } + + self = self.layer(tracing_layer); + } + + if config.enable_health_endpoints { + if self.health_check.is_none() { + let mut builder = crate::health::HealthCheckBuilder::default(); + if let Some(version) = &config.version { + builder = builder.version(version.clone()); + } + self.health_check = Some(builder.build()); + } + + if self.health_endpoint_config.is_none() { + self.health_endpoint_config = Some( + config + .health_endpoint_config + .unwrap_or_else(crate::health::HealthEndpointConfig::default), + ); + } + } + + self + } + /// Print a hot-reload dev banner if `.hot_reload(true)` is set fn print_hot_reload_banner(&self, addr: &str) { if !self.hot_reload { @@ -1006,6 +1168,45 @@ impl RustApi { } // Helper to apply status page logic (monitor, layer, route) + fn apply_health_endpoints(&mut self) { + if let Some(config) = &self.health_endpoint_config { + use crate::router::get; + + let health_check = self + .health_check + .clone() + .unwrap_or_else(|| crate::health::HealthCheckBuilder::default().build()); + + let health_path = config.health_path.clone(); + let readiness_path = config.readiness_path.clone(); + let liveness_path = config.liveness_path.clone(); + + let health_handler = { + let health_check = health_check.clone(); + move || { + let health_check = health_check.clone(); + async move { crate::health::health_response(health_check).await } + } + }; + + let readiness_handler = { + let health_check = health_check.clone(); + move || { + let health_check = health_check.clone(); + async move { crate::health::readiness_response(health_check).await } + } + }; + + let liveness_handler = || async { crate::health::liveness_response().await }; + + let router = std::mem::take(&mut self.router); + self.router = router + .route(&health_path, get(health_handler)) + .route(&readiness_path, get(readiness_handler)) + .route(&liveness_path, get(liveness_handler)); + } + } + fn apply_status_page(&mut self) { if let Some(config) = &self.status_config { let monitor = std::sync::Arc::new(crate::status::StatusMonitor::new()); @@ -1056,6 +1257,9 @@ impl RustApi { // Hot-reload mode banner self.print_hot_reload_banner(addr); + // Apply health endpoints if configured + self.apply_health_endpoints(); + // Apply status page if configured self.apply_status_page(); @@ -1086,6 +1290,9 @@ impl RustApi { // Hot-reload mode banner self.print_hot_reload_banner(addr.as_ref()); + // Apply health endpoints if configured + self.apply_health_endpoints(); + // Apply status page if configured self.apply_status_page(); @@ -1149,6 +1356,9 @@ impl RustApi { ) -> Result<(), Box> { use std::sync::Arc; + // Apply health endpoints if configured + self.apply_health_endpoints(); + // Apply status page if configured self.apply_status_page(); @@ -1188,6 +1398,9 @@ impl RustApi { ) -> Result<(), Box> { use std::sync::Arc; + // Apply health endpoints if configured + self.apply_health_endpoints(); + // Apply status page if configured self.apply_status_page(); @@ -1258,6 +1471,9 @@ impl RustApi { config.port = http_socket.port(); let http_addr = http_socket.to_string(); + // Apply health endpoints if configured + self.apply_health_endpoints(); + // Apply status page if configured self.apply_status_page(); diff --git a/crates/rustapi-core/src/extract.rs b/crates/rustapi-core/src/extract.rs index a7332adb..33f602ff 100644 --- a/crates/rustapi-core/src/extract.rs +++ b/crates/rustapi-core/src/extract.rs @@ -1014,6 +1014,50 @@ impl OperationModifier for ValidatedJson { } } +// AsyncValidatedJson - Adds request body + 422 response (same as ValidatedJson) +impl OperationModifier for AsyncValidatedJson { + fn update_operation(op: &mut Operation) { + let mut ctx = SchemaCtx::new(); + let schema_ref = T::schema(&mut ctx); + + let mut content = BTreeMap::new(); + content.insert( + "application/json".to_string(), + MediaType { + schema: Some(schema_ref), + example: None, + }, + ); + + op.request_body = Some(RequestBody { + description: None, + required: Some(true), + content, + }); + + // Add 422 Validation Error response + let mut responses_content = BTreeMap::new(); + responses_content.insert( + "application/json".to_string(), + MediaType { + schema: Some(SchemaRef::Ref { + reference: "#/components/schemas/ValidationErrorSchema".to_string(), + }), + example: None, + }, + ); + + op.responses.insert( + "422".to_string(), + ResponseSpec { + description: "Validation Error".to_string(), + content: responses_content, + headers: BTreeMap::new(), + }, + ); + } +} + // Json - Adds request body (Same as ValidatedJson) impl OperationModifier for Json { fn update_operation(op: &mut Operation) { diff --git a/crates/rustapi-core/src/health.rs b/crates/rustapi-core/src/health.rs index e273076f..434a4d71 100644 --- a/crates/rustapi-core/src/health.rs +++ b/crates/rustapi-core/src/health.rs @@ -25,7 +25,11 @@ //! } //! ``` +use crate::response::{Body, IntoResponse, Response}; +use http::{header, StatusCode}; +use rustapi_openapi::{MediaType, Operation, ResponseModifier, ResponseSpec, SchemaRef}; use serde::{Deserialize, Serialize}; +use serde_json::json; use std::collections::HashMap; use std::future::Future; use std::pin::Pin; @@ -96,6 +100,145 @@ pub struct HealthCheckResult { pub timestamp: String, } +/// Configuration for built-in health endpoints. +/// +/// By default RustAPI exposes three endpoints when enabled: +/// - `/health` - aggregated dependency health +/// - `/ready` - readiness probe for orchestrators/load balancers +/// - `/live` - lightweight liveness probe +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HealthEndpointConfig { + /// Path for the aggregated health endpoint. + pub health_path: String, + /// Path for the readiness endpoint. + pub readiness_path: String, + /// Path for the liveness endpoint. + pub liveness_path: String, +} + +impl HealthEndpointConfig { + /// Create a new configuration with default paths. + pub fn new() -> Self { + Self::default() + } + + /// Override the health endpoint path. + pub fn health_path(mut self, path: impl Into) -> Self { + self.health_path = path.into(); + self + } + + /// Override the readiness endpoint path. + pub fn readiness_path(mut self, path: impl Into) -> Self { + self.readiness_path = path.into(); + self + } + + /// Override the liveness endpoint path. + pub fn liveness_path(mut self, path: impl Into) -> Self { + self.liveness_path = path.into(); + self + } +} + +impl Default for HealthEndpointConfig { + fn default() -> Self { + Self { + health_path: "/health".to_string(), + readiness_path: "/ready".to_string(), + liveness_path: "/live".to_string(), + } + } +} + +/// JSON health response used by RustAPI's built-in health endpoints. +#[derive(Debug, Clone)] +pub struct HealthResponse { + status: StatusCode, + body: serde_json::Value, +} + +impl HealthResponse { + /// Create a new health response from an HTTP status and JSON body. + pub fn new(status: StatusCode, body: serde_json::Value) -> Self { + Self { status, body } + } + + /// Create a health response from a health check result. + pub fn from_result(result: HealthCheckResult) -> Self { + let status = if result.status.is_unhealthy() { + StatusCode::SERVICE_UNAVAILABLE + } else { + StatusCode::OK + }; + + let body = serde_json::to_value(result).unwrap_or_else(|_| { + json!({ + "status": { "unhealthy": { "reason": "failed to serialize health result" } } + }) + }); + + Self { status, body } + } +} + +impl IntoResponse for HealthResponse { + fn into_response(self) -> Response { + match serde_json::to_vec(&self.body) { + Ok(body) => http::Response::builder() + .status(self.status) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap(), + Err(err) => crate::error::ApiError::internal(format!( + "Failed to serialize health response: {}", + err + )) + .into_response(), + } + } +} + +impl ResponseModifier for HealthResponse { + fn update_response(op: &mut Operation) { + let mut content = std::collections::BTreeMap::new(); + content.insert( + "application/json".to_string(), + MediaType { + schema: Some(SchemaRef::Inline(json!({ + "type": "object", + "additionalProperties": true + }))), + example: Some(json!({ + "status": "healthy", + "checks": { + "self": "healthy" + }, + "timestamp": "1741411200.000000000Z" + })), + }, + ); + + op.responses.insert( + "200".to_string(), + ResponseSpec { + description: "Service is healthy or ready".to_string(), + content: content.clone(), + headers: Default::default(), + }, + ); + + op.responses.insert( + "503".to_string(), + ResponseSpec { + description: "Service or one of its dependencies is unhealthy".to_string(), + content, + headers: Default::default(), + }, + ); + } +} + /// Type alias for async health check functions pub type HealthCheckFn = Arc Pin + Send>> + Send + Sync>; @@ -151,6 +294,25 @@ impl HealthCheck { } } +/// Execute an aggregated health check and return an HTTP-friendly response. +pub async fn health_response(health: HealthCheck) -> HealthResponse { + HealthResponse::from_result(health.execute().await) +} + +/// Execute a readiness probe based on the configured health checks. +/// +/// Readiness currently shares the same dependency checks as the aggregated +/// health endpoint; unhealthy dependencies return `503 Service Unavailable`. +pub async fn readiness_response(health: HealthCheck) -> HealthResponse { + HealthResponse::from_result(health.execute().await) +} + +/// Return a lightweight liveness probe response. +pub async fn liveness_response() -> HealthResponse { + let result = HealthCheckBuilder::default().build().execute().await; + HealthResponse::from_result(result) +} + /// Builder for health check configuration pub struct HealthCheckBuilder { checks: HashMap, @@ -281,4 +443,15 @@ mod tests { assert_eq!(result.checks.len(), 1); assert!(result.checks.contains_key("self")); } + + #[tokio::test] + async fn readiness_response_returns_service_unavailable_for_unhealthy_checks() { + let health = HealthCheckBuilder::new(false) + .add_check("db", || async { HealthStatus::unhealthy("db offline") }) + .build(); + + let response = readiness_response(health).await.into_response(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } } diff --git a/crates/rustapi-core/src/lib.rs b/crates/rustapi-core/src/lib.rs index 71e72431..500112ee 100644 --- a/crates/rustapi-core/src/lib.rs +++ b/crates/rustapi-core/src/lib.rs @@ -98,7 +98,7 @@ pub mod __private { } // Public API -pub use app::{RustApi, RustApiConfig}; +pub use app::{ProductionDefaultsConfig, RustApi, RustApiConfig}; pub use error::{get_environment, ApiError, Environment, FieldError, Result}; pub use events::EventBus; #[cfg(feature = "cookies")] @@ -115,7 +115,10 @@ pub use handler::{ pub use hateoas::{ CursorPaginated, Link, LinkOrArray, Linkable, PageInfo, Paginated, Resource, ResourceCollection, }; -pub use health::{HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthStatus}; +pub use health::{ + HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthEndpointConfig, HealthResponse, + HealthStatus, +}; pub use http::StatusCode; #[cfg(feature = "http3")] pub use http3::{Http3Config, Http3Server}; @@ -132,7 +135,7 @@ pub use response::{ Body as ResponseBody, Created, Html, IntoResponse, NoContent, Redirect, Response, WithStatus, }; pub use router::{delete, get, patch, post, put, MethodRouter, RouteMatch, Router}; -pub use sse::{sse_response, KeepAlive, Sse, SseEvent}; +pub use sse::{sse_from_iter, sse_response, KeepAlive, Sse, SseEvent}; pub use static_files::{serve_dir, StaticFile, StaticFileConfig}; pub use stream::{StreamBody, StreamingBody, StreamingConfig}; pub use typed_path::TypedPath; diff --git a/crates/rustapi-core/tests/health_endpoints.rs b/crates/rustapi-core/tests/health_endpoints.rs new file mode 100644 index 00000000..2a311067 --- /dev/null +++ b/crates/rustapi-core/tests/health_endpoints.rs @@ -0,0 +1,133 @@ +use rustapi_core::health::{HealthCheckBuilder, HealthEndpointConfig, HealthStatus}; +use rustapi_core::RustApi; +use std::time::Duration; +use tokio::sync::oneshot; + +#[tokio::test] +async fn test_default_health_endpoints_are_available() { + let app = RustApi::new().health_endpoints(); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + drop(listener); + + let (tx, rx) = oneshot::channel(); + let addr_str = format!("127.0.0.1:{}", port); + + let server_handle = tokio::spawn(async move { + app.run_with_shutdown(&addr_str, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{}", port); + + for path in ["/health", "/ready", "/live"] { + let res = client + .get(format!("{}{}", base_url, path)) + .send() + .await + .expect("health endpoint request failed"); + + assert_eq!(res.status(), 200, "{} should return 200", path); + + let body: serde_json::Value = res.json().await.unwrap(); + assert!(body.get("status").is_some(), "{} should include status", path); + assert!( + body.get("timestamp").is_some(), + "{} should include timestamp", + path + ); + } + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; +} + +#[tokio::test] +async fn test_unhealthy_readiness_returns_503() { + let health = HealthCheckBuilder::new(false) + .add_check("database", || async { HealthStatus::unhealthy("database offline") }) + .build(); + + let app = RustApi::new().with_health_check(health); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + drop(listener); + + let (tx, rx) = oneshot::channel(); + let addr_str = format!("127.0.0.1:{}", port); + + let server_handle = tokio::spawn(async move { + app.run_with_shutdown(&addr_str, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{}", port); + + let res = client + .get(format!("{}/ready", base_url)) + .send() + .await + .expect("readiness endpoint request failed"); + + assert_eq!(res.status(), 503); + + let body = res.text().await.unwrap(); + assert!(body.contains("database offline")); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; +} + +#[tokio::test] +async fn test_custom_health_endpoint_paths() { + let config = HealthEndpointConfig::new() + .health_path("/healthz") + .readiness_path("/readyz") + .liveness_path("/livez"); + + let app = RustApi::new().health_endpoints_with_config(config); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + drop(listener); + + let (tx, rx) = oneshot::channel(); + let addr_str = format!("127.0.0.1:{}", port); + + let server_handle = tokio::spawn(async move { + app.run_with_shutdown(&addr_str, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{}", port); + + for path in ["/healthz", "/readyz", "/livez"] { + let res = client + .get(format!("{}{}", base_url, path)) + .send() + .await + .expect("custom health endpoint request failed"); + + assert_eq!(res.status(), 200, "{} should return 200", path); + } + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; +} \ No newline at end of file diff --git a/crates/rustapi-core/tests/production_defaults.rs b/crates/rustapi-core/tests/production_defaults.rs new file mode 100644 index 00000000..916aa335 --- /dev/null +++ b/crates/rustapi-core/tests/production_defaults.rs @@ -0,0 +1,125 @@ +use rustapi_core::health::HealthEndpointConfig; +use rustapi_core::{get, ProductionDefaultsConfig, RustApi}; +use std::time::Duration; +use tokio::sync::oneshot; + +#[tokio::test] +async fn test_production_defaults_enable_request_id_and_health_probes() { + async fn hello() -> &'static str { + "ok" + } + + let app = RustApi::new() + .production_defaults("users-api") + .route("/hello", get(hello)); + + assert_eq!(app.layers().len(), 2, "request ID and tracing layers should be installed"); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + drop(listener); + + let (tx, rx) = oneshot::channel(); + let addr_str = format!("127.0.0.1:{}", port); + + let server_handle = tokio::spawn(async move { + app.run_with_shutdown(&addr_str, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{}", port); + + let res = client + .get(format!("{}/hello", base_url)) + .send() + .await + .expect("hello request failed"); + assert_eq!(res.status(), 200); + assert!(res.headers().get("x-request-id").is_some()); + + let res = client + .get(format!("{}/health", base_url)) + .send() + .await + .expect("health request failed"); + assert_eq!(res.status(), 200); + let body: serde_json::Value = res.json().await.unwrap(); + assert!( + body.get("version").is_none(), + "version should be omitted when no version is configured" + ); + assert!(body.get("checks").is_some()); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; +} + +#[tokio::test] +async fn test_production_defaults_custom_config_applies_version_and_custom_paths() { + let config = ProductionDefaultsConfig::new("billing-api") + .version("1.2.3") + .health_endpoint_config( + HealthEndpointConfig::new() + .health_path("/healthz") + .readiness_path("/readyz") + .liveness_path("/livez"), + ); + + let app = RustApi::new().production_defaults_with_config(config); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let port = addr.port(); + drop(listener); + + let (tx, rx) = oneshot::channel(); + let addr_str = format!("127.0.0.1:{}", port); + + let server_handle = tokio::spawn(async move { + app.run_with_shutdown(&addr_str, async { + rx.await.ok(); + }) + .await + }); + + tokio::time::sleep(Duration::from_millis(200)).await; + let client = reqwest::Client::new(); + let base_url = format!("http://127.0.0.1:{}", port); + + for path in ["/healthz", "/readyz", "/livez"] { + let res = client + .get(format!("{}{}", base_url, path)) + .send() + .await + .expect("probe request failed"); + assert_eq!(res.status(), 200); + } + + let res = client + .get(format!("{}/healthz", base_url)) + .send() + .await + .expect("healthz request failed"); + let body: serde_json::Value = res.json().await.unwrap(); + assert_eq!(body.get("version"), Some(&serde_json::Value::String("1.2.3".to_string()))); + + tx.send(()).unwrap(); + let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; +} + +#[test] +fn test_production_defaults_can_disable_optional_parts() { + let app = RustApi::new().production_defaults_with_config( + ProductionDefaultsConfig::new("minimal-api") + .request_id(false) + .tracing(false) + .health_endpoints(false), + ); + + assert_eq!(app.layers().len(), 0); +} \ No newline at end of file diff --git a/crates/rustapi-extras/Cargo.toml b/crates/rustapi-extras/Cargo.toml index 26db80ac..91ad697d 100644 --- a/crates/rustapi-extras/Cargo.toml +++ b/crates/rustapi-extras/Cargo.toml @@ -51,6 +51,7 @@ envy = { version = "0.4", optional = true } # Cookies (feature-gated) cookie = { version = "0.18", optional = true } +redis = { version = "0.27", optional = true, default-features = false, features = ["tokio-comp"] } # Insight (feature-gated) - reuses dashmap from rate-limit urlencoding = { version = "2.1", optional = true } @@ -123,6 +124,8 @@ structured-logging = [] csrf = ["dep:cookie", "dep:rand", "dep:base64"] oauth2-client = ["dep:sha2", "dep:rand", "dep:base64", "dep:reqwest", "dep:urlencoding"] audit = ["dep:rand"] +session = ["dep:cookie", "dep:uuid"] +session-redis = ["session", "dep:redis"] # Replay (time-travel debugging) replay = ["dep:reqwest", "dep:dashmap", "dep:uuid", "rustapi-core/replay"] @@ -134,4 +137,4 @@ extras = ["jwt", "cors", "rate-limit"] observability = ["otel", "structured-logging"] # Full feature set (retry temporarily disabled) -full = ["extras", "config", "cookies", "sqlx", "insight", "webhook", "timeout", "guard", "logging", "circuit-breaker", "security-headers", "api-key", "cache", "dedup", "sanitization", "retry", "otel", "structured-logging", "csrf", "oauth2-client", "audit", "replay"] +full = ["extras", "config", "cookies", "sqlx", "insight", "webhook", "timeout", "guard", "logging", "circuit-breaker", "security-headers", "api-key", "cache", "dedup", "sanitization", "retry", "otel", "structured-logging", "csrf", "oauth2-client", "audit", "session", "session-redis", "replay"] diff --git a/crates/rustapi-extras/src/lib.rs b/crates/rustapi-extras/src/lib.rs index 5c911761..75d302ad 100644 --- a/crates/rustapi-extras/src/lib.rs +++ b/crates/rustapi-extras/src/lib.rs @@ -206,6 +206,18 @@ pub use oauth2::{ TokenError, TokenResponse, }; +#[cfg(feature = "session")] +pub mod session; + +#[cfg(feature = "session")] +pub use session::{ + MemorySessionStore, Session, SessionConfig, SessionError, SessionLayer, SessionRecord, + SessionStore, +}; + +#[cfg(feature = "session-redis")] +pub use session::RedisSessionStore; + #[cfg(feature = "audit")] pub mod audit; diff --git a/crates/rustapi-extras/src/oauth2/tokens.rs b/crates/rustapi-extras/src/oauth2/tokens.rs index 1d9a3ea8..bfe4e66e 100644 --- a/crates/rustapi-extras/src/oauth2/tokens.rs +++ b/crates/rustapi-extras/src/oauth2/tokens.rs @@ -151,18 +151,12 @@ pub struct PkceVerifier { } impl PkceVerifier { - /// Generate a new PKCE verifier with S256 challenge. - pub fn generate() -> Self { + /// Rebuild a PKCE verifier from an existing verifier string. + pub fn new(verifier: impl Into) -> Self { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - use rand::{rngs::OsRng, RngCore}; - - // Generate 32 random bytes for the verifier - let mut verifier_bytes = [0u8; 32]; - OsRng.fill_bytes(&mut verifier_bytes); - let verifier = URL_SAFE_NO_PAD.encode(verifier_bytes); - - // Create S256 challenge: BASE64URL(SHA256(verifier)) use sha2::{Digest, Sha256}; + + let verifier = verifier.into(); let mut hasher = Sha256::new(); hasher.update(verifier.as_bytes()); let hash = hasher.finalize(); @@ -175,6 +169,17 @@ impl PkceVerifier { } } + /// Generate a new PKCE verifier with S256 challenge. + pub fn generate() -> Self { + use rand::{rngs::OsRng, RngCore}; + + // Generate 32 random bytes for the verifier + let mut verifier_bytes = [0u8; 32]; + OsRng.fill_bytes(&mut verifier_bytes); + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + Self::new(URL_SAFE_NO_PAD.encode(verifier_bytes)) + } + /// Get the code verifier (for token exchange). pub fn verifier(&self) -> &str { &self.verifier @@ -256,6 +261,10 @@ mod tests { // Verifier and challenge should be different assert_ne!(pkce.verifier(), pkce.challenge()); + + let rebuilt = PkceVerifier::new(pkce.verifier().to_string()); + assert_eq!(rebuilt.verifier(), pkce.verifier()); + assert_eq!(rebuilt.challenge(), pkce.challenge()); } #[test] diff --git a/crates/rustapi-extras/src/session/mod.rs b/crates/rustapi-extras/src/session/mod.rs new file mode 100644 index 00000000..92aba915 --- /dev/null +++ b/crates/rustapi-extras/src/session/mod.rs @@ -0,0 +1,967 @@ +//! Session middleware, extractors, and storage backends. +//! +//! This module provides a cookie-backed session flow that stores session state in +//! pluggable backends. It is intentionally small and framework-native: handlers +//! receive a [`Session`] extractor, mutate typed values, and the middleware +//! persists those changes after the response is produced. +//! +//! # Example +//! +//! ```rust,ignore +//! use rustapi_rs::prelude::*; +//! use rustapi_rs::extras::session::{MemorySessionStore, Session, SessionConfig, SessionLayer}; +//! use serde::{Deserialize, Serialize}; +//! +//! #[derive(Deserialize)] +//! struct LoginPayload { +//! user_id: String, +//! } +//! +//! #[derive(Serialize)] +//! struct MeResponse { +//! user_id: String, +//! } +//! +//! async fn login(session: Session, Json(payload): Json) -> Result> { +//! session.cycle_id().await; +//! session.insert("user_id", &payload.user_id).await?; +//! Ok(Json(MeResponse { user_id: payload.user_id })) +//! } +//! +//! async fn me(session: Session) -> Result> { +//! let user_id = session +//! .get::("user_id") +//! .await? +//! .ok_or_else(|| ApiError::unauthorized("Not logged in"))?; +//! +//! Ok(Json(MeResponse { user_id })) +//! } +//! +//! let app = RustApi::new() +//! .layer(SessionLayer::new( +//! MemorySessionStore::new(), +//! SessionConfig::new().cookie_name("rustapi_session"), +//! )) +//! .route("/login", post(login)) +//! .route("/me", get(me)); +//! ``` + +use async_trait::async_trait; +use cookie::{Cookie, SameSite}; +use http::{header, HeaderValue}; +use rustapi_core::middleware::{BoxedNext, MiddlewareLayer}; +use rustapi_core::{ApiError, FromRequestParts, IntoResponse, Request, Response, Result}; +use rustapi_openapi::{Operation, OperationModifier}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{BTreeMap, HashMap}; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::sync::{Mutex, RwLock}; +use tracing::warn; +use uuid::Uuid; + +/// Result type for session operations. +pub type SessionResult = std::result::Result; + +/// Arbitrary JSON-backed session data. +pub type SessionData = BTreeMap; + +/// Errors that can occur when loading, mutating, or persisting sessions. +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + /// The configured store failed to read data. + #[error("Failed to read session data: {0}")] + Read(String), + + /// The configured store failed to persist data. + #[error("Failed to persist session data: {0}")] + Write(String), + + /// A serialized value could not be converted to JSON. + #[error("Failed to serialize session value: {0}")] + Serialize(String), + + /// A JSON value could not be converted back to the requested type. + #[error("Failed to deserialize session value: {0}")] + Deserialize(String), + + /// A store-specific configuration error occurred. + #[error("Invalid session store configuration: {0}")] + Config(String), +} + +impl From for ApiError { + fn from(error: SessionError) -> Self { + ApiError::internal(error.to_string()) + } +} + +/// Persistent representation of a session. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SessionRecord { + /// Stable session identifier stored in the cookie. + pub id: String, + /// Arbitrary JSON data for the session. + #[serde(default)] + pub data: SessionData, + /// UNIX timestamp in seconds when the session expires. + pub expires_at: u64, +} + +impl SessionRecord { + /// Create a new record with the given TTL. + pub fn new(id: impl Into, data: SessionData, ttl: Duration) -> Self { + let expires_at = current_unix_seconds().saturating_add(ttl.as_secs()); + Self { + id: id.into(), + data, + expires_at, + } + } + + /// Returns true when the record should be treated as expired. + pub fn is_expired(&self) -> bool { + self.expires_at <= current_unix_seconds() + } + + /// Returns the remaining TTL in seconds, saturating at zero. + pub fn ttl_seconds(&self) -> u64 { + self.expires_at.saturating_sub(current_unix_seconds()) + } +} + +/// Storage backend contract for sessions. +#[async_trait] +pub trait SessionStore: Send + Sync { + /// Load a session by identifier. + async fn load(&self, session_id: &str) -> SessionResult>; + + /// Persist a session record. + async fn save(&self, record: SessionRecord) -> SessionResult<()>; + + /// Delete a session by identifier. + async fn delete(&self, session_id: &str) -> SessionResult<()>; +} + +/// In-memory session store suitable for tests, examples, and single-node deployments. +#[derive(Debug, Clone, Default)] +pub struct MemorySessionStore { + sessions: Arc>>, +} + +impl MemorySessionStore { + /// Create a new empty in-memory session store. + pub fn new() -> Self { + Self::default() + } + + /// Number of currently retained sessions, excluding entries cleaned on access. + pub async fn len(&self) -> usize { + self.sessions.read().await.len() + } + + /// Returns true if the store currently has no retained sessions. + pub async fn is_empty(&self) -> bool { + self.len().await == 0 + } +} + +#[async_trait] +impl SessionStore for MemorySessionStore { + async fn load(&self, session_id: &str) -> SessionResult> { + if let Some(record) = self.sessions.read().await.get(session_id).cloned() { + if record.is_expired() { + self.sessions.write().await.remove(session_id); + return Ok(None); + } + + return Ok(Some(record)); + } + + Ok(None) + } + + async fn save(&self, record: SessionRecord) -> SessionResult<()> { + self.sessions + .write() + .await + .insert(record.id.clone(), record); + Ok(()) + } + + async fn delete(&self, session_id: &str) -> SessionResult<()> { + self.sessions.write().await.remove(session_id); + Ok(()) + } +} + +/// Configuration for cookie-backed sessions. +#[derive(Clone, Debug)] +pub struct SessionConfig { + /// Cookie name used to transport the session identifier. + pub cookie_name: String, + /// Cookie path. + pub cookie_path: String, + /// Optional cookie domain. + pub cookie_domain: Option, + /// Whether the cookie should be sent over HTTPS only. + pub cookie_secure: bool, + /// Whether JavaScript should be denied access to the cookie. + pub cookie_http_only: bool, + /// SameSite value for the session cookie. + pub cookie_same_site: SameSite, + /// Logical TTL for stored session records. + pub ttl: Duration, + /// If enabled, every successfully loaded session is re-saved with a fresh expiry. + pub rolling: bool, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + cookie_name: "rustapi_session".to_string(), + cookie_path: "/".to_string(), + cookie_domain: None, + cookie_secure: true, + cookie_http_only: true, + cookie_same_site: SameSite::Lax, + ttl: Duration::from_secs(60 * 60 * 24), + rolling: true, + } + } +} + +impl SessionConfig { + /// Create a default session configuration. + pub fn new() -> Self { + Self::default() + } + + /// Override the cookie name. + pub fn cookie_name(mut self, value: impl Into) -> Self { + self.cookie_name = value.into(); + self + } + + /// Override the cookie path. + pub fn cookie_path(mut self, value: impl Into) -> Self { + self.cookie_path = value.into(); + self + } + + /// Override the cookie domain. + pub fn cookie_domain(mut self, value: impl Into) -> Self { + self.cookie_domain = Some(value.into()); + self + } + + /// Toggle the secure flag. + pub fn secure(mut self, secure: bool) -> Self { + self.cookie_secure = secure; + self + } + + /// Toggle the HTTP only flag. + pub fn http_only(mut self, value: bool) -> Self { + self.cookie_http_only = value; + self + } + + /// Override the SameSite setting. + pub fn same_site(mut self, value: SameSite) -> Self { + self.cookie_same_site = value; + self + } + + /// Override the session TTL. + pub fn ttl(mut self, ttl: Duration) -> Self { + self.ttl = ttl; + self + } + + /// Toggle rolling expiration. + pub fn rolling(mut self, rolling: bool) -> Self { + self.rolling = rolling; + self + } +} + +#[derive(Debug, Clone)] +struct SessionState { + id: Option, + data: SessionData, + loaded: bool, + dirty: bool, + destroyed: bool, + rotate_id: bool, +} + +impl SessionState { + fn from_record(record: Option) -> Self { + match record { + Some(record) => Self { + id: Some(record.id), + data: record.data, + loaded: true, + dirty: false, + destroyed: false, + rotate_id: false, + }, + None => Self { + id: None, + data: SessionData::new(), + loaded: false, + dirty: false, + destroyed: false, + rotate_id: false, + }, + } + } + + fn ensure_id(&mut self) -> String { + if self.id.is_none() { + self.id = Some(Uuid::new_v4().to_string()); + } + + self.id.clone().expect("session id should be present") + } +} + +/// Request extractor for reading and mutating the current session. +#[derive(Clone)] +pub struct Session { + inner: Arc>, +} + +impl Session { + fn new(inner: Arc>) -> Self { + Self { inner } + } + + /// Get the current session identifier, if already assigned. + pub async fn id(&self) -> Option { + self.inner.lock().await.id.clone() + } + + /// Returns true if the session currently holds the given key. + pub async fn contains(&self, key: &str) -> bool { + self.inner.lock().await.data.contains_key(key) + } + + /// Returns a full copy of the current session data. + pub async fn entries(&self) -> SessionData { + self.inner.lock().await.data.clone() + } + + /// Returns the number of stored keys. + pub async fn len(&self) -> usize { + self.inner.lock().await.data.len() + } + + /// Returns true if the current session contains no keys. + pub async fn is_empty(&self) -> bool { + self.inner.lock().await.data.is_empty() + } + + /// Read a typed value from the session. + pub async fn get(&self, key: &str) -> SessionResult> { + let guard = self.inner.lock().await; + guard + .data + .get(key) + .cloned() + .map(serde_json::from_value) + .transpose() + .map_err(|error| SessionError::Deserialize(error.to_string())) + } + + /// Read the raw JSON value stored for a key. + pub async fn get_value(&self, key: &str) -> Option { + self.inner.lock().await.data.get(key).cloned() + } + + /// Insert or replace a typed value. + pub async fn insert(&self, key: impl Into, value: T) -> SessionResult<()> { + let mut guard = self.inner.lock().await; + let value = serde_json::to_value(value) + .map_err(|error| SessionError::Serialize(error.to_string()))?; + guard.ensure_id(); + guard.data.insert(key.into(), value); + guard.dirty = true; + guard.destroyed = false; + Ok(()) + } + + /// Remove a value from the session, returning the raw JSON if present. + pub async fn remove(&self, key: &str) -> Option { + let mut guard = self.inner.lock().await; + let removed = guard.data.remove(key); + if removed.is_some() { + guard.dirty = true; + } + removed + } + + /// Clear all values while keeping the session container alive. + pub async fn clear(&self) { + let mut guard = self.inner.lock().await; + if !guard.data.is_empty() || guard.loaded { + guard.dirty = true; + } + guard.data.clear(); + guard.destroyed = false; + } + + /// Mark the session for deletion and clear all values. + pub async fn destroy(&self) { + let mut guard = self.inner.lock().await; + guard.data.clear(); + guard.dirty = true; + guard.destroyed = true; + } + + /// Rotate the session identifier on the next persistence cycle. + pub async fn cycle_id(&self) { + let mut guard = self.inner.lock().await; + guard.ensure_id(); + guard.rotate_id = true; + guard.dirty = true; + guard.destroyed = false; + } +} + +impl std::fmt::Debug for Session { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Session").finish_non_exhaustive() + } +} + +impl FromRequestParts for Session { + fn from_request_parts(req: &Request) -> Result { + req.extensions() + .get::() + .cloned() + .ok_or_else(|| ApiError::internal("Session middleware is missing. Add SessionLayer first.")) + } +} + +impl OperationModifier for Session { + fn update_operation(_op: &mut Operation) {} +} + +/// Middleware that loads session state before the handler and persists it afterwards. +#[derive(Clone)] +pub struct SessionLayer { + store: Arc, + config: Arc, +} + +impl SessionLayer +where + S: SessionStore + 'static, +{ + /// Create a new session layer. + pub fn new(store: S, config: SessionConfig) -> Self { + Self { + store: Arc::new(store), + config: Arc::new(config), + } + } + + /// Create a new session layer from a shared store instance. + pub fn from_arc(store: Arc, config: SessionConfig) -> Self { + Self { + store, + config: Arc::new(config), + } + } +} + +impl MiddlewareLayer for SessionLayer +where + S: SessionStore + 'static, +{ + fn call( + &self, + mut req: Request, + next: BoxedNext, + ) -> Pin + Send + 'static>> { + let store = self.store.clone(); + let config = self.config.clone(); + + Box::pin(async move { + let incoming_session_id = cookie_value(req.headers(), &config.cookie_name); + let mut clear_stale_cookie = false; + + let record = if let Some(session_id) = incoming_session_id.as_deref() { + match store.load(session_id).await { + Ok(Some(record)) if !record.is_expired() => Some(record), + Ok(Some(_)) => { + if let Err(error) = store.delete(session_id).await { + warn!(error = %error, session_id, "failed to delete expired session record"); + } + clear_stale_cookie = true; + None + } + Ok(None) => { + clear_stale_cookie = true; + None + } + Err(error) => return ApiError::from(error).into_response(), + } + } else { + None + }; + + let previous_id = record.as_ref().map(|record| record.id.clone()); + let state = Arc::new(Mutex::new(SessionState::from_record(record))); + req.extensions_mut().insert(Session::new(state.clone())); + + let mut response = next(req).await; + + let snapshot = state.lock().await.clone(); + + if snapshot.destroyed { + if let Some(session_id) = snapshot.id.as_deref().or(previous_id.as_deref()) { + if let Err(error) = store.delete(session_id).await { + return ApiError::from(error).into_response(); + } + } + + append_clear_cookie(&mut response, &config); + return response; + } + + let should_persist = if snapshot.loaded { + snapshot.dirty || config.rolling + } else { + snapshot.dirty && !snapshot.data.is_empty() + }; + + if should_persist { + let mut session_id = snapshot.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string()); + + if snapshot.rotate_id { + let rotated_id = Uuid::new_v4().to_string(); + if let Some(previous_id) = snapshot.id.as_deref() { + if previous_id != rotated_id { + if let Err(error) = store.delete(previous_id).await { + return ApiError::from(error).into_response(); + } + } + } + session_id = rotated_id; + } + + let record = SessionRecord::new(session_id.clone(), snapshot.data.clone(), config.ttl); + + if let Err(error) = store.save(record).await { + return ApiError::from(error).into_response(); + } + + append_session_cookie(&mut response, &config, &session_id); + return response; + } + + if clear_stale_cookie { + append_clear_cookie(&mut response, &config); + } + + response + }) + } + + fn clone_box(&self) -> Box { + Box::new(Self { + store: self.store.clone(), + config: self.config.clone(), + }) + } +} + +#[cfg(feature = "session-redis")] +use redis::AsyncCommands; + +/// Redis-backed session storage. +#[cfg(feature = "session-redis")] +#[derive(Clone)] +pub struct RedisSessionStore { + client: redis::Client, + key_prefix: String, +} + +#[cfg(feature = "session-redis")] +impl std::fmt::Debug for RedisSessionStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RedisSessionStore") + .field("key_prefix", &self.key_prefix) + .finish_non_exhaustive() + } +} + +#[cfg(feature = "session-redis")] +impl RedisSessionStore { + /// Create a Redis session store from an existing client. + pub fn new(client: redis::Client) -> Self { + Self { + client, + key_prefix: "rustapi:session:".to_string(), + } + } + + /// Create a Redis session store from a connection URL. + pub fn from_url(url: &str) -> SessionResult { + let client = redis::Client::open(url) + .map_err(|error| SessionError::Config(error.to_string()))?; + Ok(Self::new(client)) + } + + /// Override the key prefix used for session records. + pub fn key_prefix(mut self, value: impl Into) -> Self { + self.key_prefix = value.into(); + self + } + + fn key(&self, session_id: &str) -> String { + format!("{}{}", self.key_prefix, session_id) + } +} + +#[cfg(feature = "session-redis")] +#[async_trait] +impl SessionStore for RedisSessionStore { + async fn load(&self, session_id: &str) -> SessionResult> { + let mut connection = self + .client + .get_multiplexed_async_connection() + .await + .map_err(|error| SessionError::Read(error.to_string()))?; + + let payload: Option = connection + .get(self.key(session_id)) + .await + .map_err(|error| SessionError::Read(error.to_string()))?; + + payload + .map(|payload| { + serde_json::from_str(&payload) + .map_err(|error| SessionError::Deserialize(error.to_string())) + }) + .transpose() + } + + async fn save(&self, record: SessionRecord) -> SessionResult<()> { + let ttl = record.ttl_seconds().max(1); + let payload = serde_json::to_string(&record) + .map_err(|error| SessionError::Serialize(error.to_string()))?; + + let mut connection = self + .client + .get_multiplexed_async_connection() + .await + .map_err(|error| SessionError::Write(error.to_string()))?; + + connection + .set_ex(self.key(&record.id), payload, ttl) + .await + .map_err(|error| SessionError::Write(error.to_string())) + } + + async fn delete(&self, session_id: &str) -> SessionResult<()> { + let mut connection = self + .client + .get_multiplexed_async_connection() + .await + .map_err(|error| SessionError::Write(error.to_string()))?; + + let _: usize = connection + .del(self.key(session_id)) + .await + .map_err(|error| SessionError::Write(error.to_string()))?; + + Ok(()) + } +} + +fn current_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn cookie_value(headers: &http::HeaderMap, cookie_name: &str) -> Option { + headers + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(|value| { + Cookie::split_parse(value) + .filter_map(|cookie| cookie.ok()) + .find(|cookie| cookie.name() == cookie_name) + .map(|cookie| cookie.value().to_string()) + }) +} + +fn append_session_cookie(response: &mut Response, config: &SessionConfig, session_id: &str) { + let mut cookie = Cookie::build((config.cookie_name.clone(), session_id.to_string())) + .path(config.cookie_path.clone()) + .secure(config.cookie_secure) + .http_only(config.cookie_http_only) + .same_site(config.cookie_same_site) + .max_age(cookie::time::Duration::seconds(config.ttl.as_secs() as i64)); + + if let Some(domain) = &config.cookie_domain { + cookie = cookie.domain(domain.clone()); + } + + response.headers_mut().append( + header::SET_COOKIE, + cookie_header_value(cookie.build()), + ); +} + +fn append_clear_cookie(response: &mut Response, config: &SessionConfig) { + let mut cookie = Cookie::build((config.cookie_name.clone(), String::new())) + .path(config.cookie_path.clone()) + .secure(config.cookie_secure) + .http_only(config.cookie_http_only) + .same_site(config.cookie_same_site) + .max_age(cookie::time::Duration::seconds(0)); + + if let Some(domain) = &config.cookie_domain { + cookie = cookie.domain(domain.clone()); + } + + response.headers_mut().append( + header::SET_COOKIE, + cookie_header_value(cookie.build()), + ); +} + +fn cookie_header_value(cookie: Cookie<'static>) -> HeaderValue { + HeaderValue::from_str(&cookie.to_string()).unwrap_or_else(|_| HeaderValue::from_static("")) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::StatusCode; + use rustapi_core::{get, post, Body, Json, NoContent, RustApi}; + use rustapi_openapi::ResponseModifier; + use rustapi_testing::{TestClient, TestRequest}; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Deserialize, Serialize)] + struct LoginPayload { + user_id: String, + } + + #[derive(Debug, Deserialize, Serialize, PartialEq)] + struct SessionUser { + user_id: String, + refreshed: bool, + } + + enum TestSessionResponse { + User(SessionUser), + Empty, + Error(ApiError), + } + + impl IntoResponse for TestSessionResponse { + fn into_response(self) -> Response { + match self { + Self::User(body) => Json(body).into_response(), + Self::Empty => NoContent.into_response(), + Self::Error(error) => error.into_response(), + } + } + } + + impl ResponseModifier for TestSessionResponse { + fn update_response(_op: &mut Operation) {} + } + + async fn login(session: Session, body: Body) -> TestSessionResponse { + let payload: LoginPayload = match serde_json::from_slice(&body) { + Ok(payload) => payload, + Err(error) => return TestSessionResponse::Error(ApiError::bad_request(error.to_string())), + }; + + session.cycle_id().await; + if let Err(error) = session.insert("user_id", &payload.user_id).await { + return TestSessionResponse::Error(ApiError::from(error)); + } + if let Err(error) = session.insert("refreshed", false).await { + return TestSessionResponse::Error(ApiError::from(error)); + } + + TestSessionResponse::User(SessionUser { + user_id: payload.user_id, + refreshed: false, + }) + } + + async fn me(session: Session) -> TestSessionResponse { + let user_id = match session.get::("user_id").await { + Ok(Some(user_id)) => user_id, + Ok(None) => return TestSessionResponse::Error(ApiError::unauthorized("Not logged in")), + Err(error) => return TestSessionResponse::Error(ApiError::from(error)), + }; + + let refreshed = match session.get::("refreshed").await { + Ok(Some(refreshed)) => refreshed, + Ok(None) => false, + Err(error) => return TestSessionResponse::Error(ApiError::from(error)), + }; + + TestSessionResponse::User(SessionUser { user_id, refreshed }) + } + + async fn refresh(session: Session) -> TestSessionResponse { + session.cycle_id().await; + if let Err(error) = session.insert("refreshed", true).await { + return TestSessionResponse::Error(ApiError::from(error)); + } + + let user_id = match session.get::("user_id").await { + Ok(Some(user_id)) => user_id, + Ok(None) => return TestSessionResponse::Error(ApiError::unauthorized("Not logged in")), + Err(error) => return TestSessionResponse::Error(ApiError::from(error)), + }; + + TestSessionResponse::User(SessionUser { + user_id, + refreshed: true, + }) + } + + async fn logout(session: Session) -> TestSessionResponse { + session.destroy().await; + TestSessionResponse::Empty + } + + fn set_cookie_value(response: &rustapi_testing::TestResponse) -> String { + response + .headers() + .get(header::SET_COOKIE) + .and_then(|value| value.to_str().ok()) + .expect("missing set-cookie header") + .to_string() + } + + fn cookie_pair(set_cookie: &str) -> String { + set_cookie + .split(';') + .next() + .expect("cookie pair should exist") + .to_string() + } + + #[tokio::test] + async fn login_refresh_logout_flow_works() { + let store = MemorySessionStore::new(); + let app = RustApi::new() + .layer(SessionLayer::new( + store.clone(), + SessionConfig::new().cookie_name("sid").secure(false), + )) + .route("/login", post(login)) + .route("/me", get(me)) + .route("/refresh", post(refresh)) + .route("/logout", post(logout)); + + let client = TestClient::new(app); + + let login_response = client + .request(TestRequest::post("/login").json(&LoginPayload { + user_id: "user-42".to_string(), + })) + .await; + + login_response.assert_status(StatusCode::OK); + let login_cookie = set_cookie_value(&login_response); + let login_pair = cookie_pair(&login_cookie); + assert!(login_pair.starts_with("sid=")); + + let me_response = client + .request(TestRequest::get("/me").header("Cookie", &login_pair)) + .await; + + me_response.assert_status(StatusCode::OK); + me_response.assert_json(&SessionUser { + user_id: "user-42".to_string(), + refreshed: false, + }); + + let first_session_id = login_pair.trim_start_matches("sid=").to_string(); + assert!(store.load(&first_session_id).await.unwrap().is_some()); + + let refresh_response = client + .request(TestRequest::post("/refresh").header("Cookie", &login_pair)) + .await; + + refresh_response.assert_status(StatusCode::OK); + let refreshed_cookie = set_cookie_value(&refresh_response); + let refreshed_pair = cookie_pair(&refreshed_cookie); + assert_ne!(login_pair, refreshed_pair); + + let refreshed_me = client + .request(TestRequest::get("/me").header("Cookie", &refreshed_pair)) + .await; + + refreshed_me.assert_status(StatusCode::OK); + refreshed_me.assert_json(&SessionUser { + user_id: "user-42".to_string(), + refreshed: true, + }); + + let logout_response = client + .request(TestRequest::post("/logout").header("Cookie", &refreshed_pair)) + .await; + + logout_response.assert_status(StatusCode::NO_CONTENT); + let cleared_cookie = set_cookie_value(&logout_response); + assert!(cleared_cookie.contains("Max-Age=0") || cleared_cookie.contains("Max-Age=0;")); + + let after_logout = client + .request(TestRequest::get("/me").header("Cookie", &refreshed_pair)) + .await; + + after_logout.assert_status(StatusCode::UNAUTHORIZED); + assert!(store.is_empty().await); + } + + #[tokio::test] + async fn stale_cookie_is_cleared() { + let app = RustApi::new() + .layer(SessionLayer::new( + MemorySessionStore::new(), + SessionConfig::new().cookie_name("sid").secure(false), + )) + .route("/me", get(me)); + + let client = TestClient::new(app); + let response = client + .request(TestRequest::get("/me").header("Cookie", "sid=missing")) + .await; + + response.assert_status(StatusCode::UNAUTHORIZED); + let cleared_cookie = set_cookie_value(&response); + assert!(cleared_cookie.contains("sid=")); + assert!(cleared_cookie.contains("Max-Age=0")); + } + + #[cfg(feature = "session-redis")] + #[test] + fn redis_store_uses_configurable_key_prefix() { + let store = RedisSessionStore::from_url("redis://127.0.0.1/") + .unwrap() + .key_prefix("custom:sessions:"); + + assert_eq!(store.key("abc"), "custom:sessions:abc"); + } +} \ No newline at end of file diff --git a/crates/rustapi-rs/Cargo.toml b/crates/rustapi-rs/Cargo.toml index b9b610b1..be100da4 100644 --- a/crates/rustapi-rs/Cargo.toml +++ b/crates/rustapi-rs/Cargo.toml @@ -16,12 +16,14 @@ readme = "README.md" rustapi-core = { workspace = true, default-features = false } rustapi-macros = { workspace = true } rustapi-extras = { workspace = true, optional = true } +rustapi-jobs = { workspace = true, optional = true } rustapi-toon = { workspace = true, optional = true } rustapi-ws = { workspace = true, optional = true } rustapi-view = { workspace = true, optional = true } rustapi-grpc = { workspace = true, optional = true } rustapi-validate = { workspace = true } async-trait = { workspace = true } +futures-util = { workspace = true } # Re-exports for user convenience tokio = { workspace = true } @@ -82,6 +84,10 @@ extras-sanitization = ["dep:rustapi-extras", "rustapi-extras/sanitization"] extras-otel = ["dep:rustapi-extras", "rustapi-extras/otel"] extras-structured-logging = ["dep:rustapi-extras", "rustapi-extras/structured-logging"] extras-replay = ["dep:rustapi-extras", "rustapi-extras/replay"] +extras-oauth2-client = ["dep:rustapi-extras", "rustapi-extras/oauth2-client"] +extras-session = ["dep:rustapi-extras", "rustapi-extras/session"] +extras-session-redis = ["dep:rustapi-extras", "rustapi-extras/session-redis"] +extras-jobs = ["dep:rustapi-jobs"] extras-all = [ "extras-jwt", "extras-cors", @@ -101,6 +107,10 @@ extras-all = [ "extras-sanitization", "extras-otel", "extras-structured-logging", + "extras-oauth2-client", + "extras-session", + "extras-session-redis", + "extras-jobs", "extras-replay", ] @@ -137,6 +147,10 @@ sanitization = ["extras-sanitization"] otel = ["extras-otel"] structured-logging = ["extras-structured-logging"] replay = ["extras-replay"] +oauth2-client = ["extras-oauth2-client"] +session = ["extras-session"] +session-redis = ["extras-session-redis"] +jobs = ["extras-jobs"] extras = ["extras-jwt", "extras-cors", "extras-rate-limit"] # Canonical aggregate diff --git a/crates/rustapi-rs/examples/README.md b/crates/rustapi-rs/examples/README.md new file mode 100644 index 00000000..566f2079 --- /dev/null +++ b/crates/rustapi-rs/examples/README.md @@ -0,0 +1,94 @@ +# RustAPI Examples + +This directory contains the in-repository examples for the `rustapi-rs` facade crate. + +## Available examples + +### `auth_api` + +Shows cookie-backed login, session refresh, logout, and session inspection using the built-in session middleware. + +Run it with: + +```sh +cargo run -p rustapi-rs --example auth_api --features extras-session +``` + +Then try: + +- `POST http://127.0.0.1:3000/auth/login` with `{"user_id":"demo-user"}` +- `GET http://127.0.0.1:3000/auth/me` +- `POST http://127.0.0.1:3000/auth/refresh` +- `POST http://127.0.0.1:3000/auth/logout` + +### `typed_path_poc` + +Shows typed path definitions, type-safe route registration, and URI generation with `TypedPath`. + +### `full_crud_api` + +Shows a compact in-memory CRUD API with list/create/read/update/delete routes. + +Run it with: + +```sh +cargo run -p rustapi-rs --example full_crud_api +``` + +### `streaming_api` + +Shows Server-Sent Events (SSE) with a small progress feed. + +Run it with: + +```sh +cargo run -p rustapi-rs --example streaming_api +``` + +Then open: + +- `http://127.0.0.1:3000/events` + +### `jobs_api` + +Shows an in-memory job queue, enqueue endpoint, and manual worker tick endpoint. + +Run it with: + +```sh +cargo run -p rustapi-rs --example jobs_api --features extras-jobs +``` + +Then try: + +- `POST http://127.0.0.1:3000/jobs/email` +- `POST http://127.0.0.1:3000/jobs/process-next` +- `GET http://127.0.0.1:3000/jobs/stats` +Run it with: + +```sh +cargo run -p rustapi-rs --example typed_path_poc +``` + +### `status_demo` + +Shows the automatic status page and a few endpoints that generate traffic, latency, and failures for demonstration purposes. + +Run it with: + +```sh +cargo run -p rustapi-rs --example status_demo +``` + +Then open: + +- `http://127.0.0.1:3000/status` +- `http://127.0.0.1:3000/fast` +- `http://127.0.0.1:3000/slow` +- `http://127.0.0.1:3000/flaky` + +## Notes + +- Keep this file aligned with the actual `.rs` files in this directory. +- User-facing examples should import from `rustapi_rs::prelude::*` unless the example is explicitly about internals. +- Additional example ideas tracked in `tasks.md` are roadmap items until their files exist here. diff --git a/crates/rustapi-rs/examples/auth_api.rs b/crates/rustapi-rs/examples/auth_api.rs new file mode 100644 index 00000000..65275fef --- /dev/null +++ b/crates/rustapi-rs/examples/auth_api.rs @@ -0,0 +1,111 @@ +#[cfg(not(any(feature = "extras-session", feature = "session")))] +fn main() { + eprintln!( + "Run this example with session support enabled:\n cargo run -p rustapi-rs --example auth_api --features extras-session" + ); +} + +#[cfg(any(feature = "extras-session", feature = "session"))] +use rustapi_rs::extras::session::{MemorySessionStore, Session, SessionConfig, SessionLayer}; +#[cfg(any(feature = "extras-session", feature = "session"))] +use rustapi_rs::prelude::*; +#[cfg(any(feature = "extras-session", feature = "session"))] +use std::time::Duration; + +#[cfg(any(feature = "extras-session", feature = "session"))] +#[derive(Debug, Deserialize, Schema)] +struct LoginRequest { + user_id: String, +} + +#[cfg(any(feature = "extras-session", feature = "session"))] +#[derive(Debug, Serialize, Schema)] +struct SessionView { + authenticated: bool, + user_id: Option, + refreshed: bool, + session_id: Option, +} + +#[cfg(any(feature = "extras-session", feature = "session"))] +async fn session_view(session: &Session) -> SessionView { + let user_id = session.get::("user_id").await.ok().flatten(); + let refreshed = session + .get::("refreshed") + .await + .ok() + .flatten() + .unwrap_or(false); + let session_id = session.id().await; + + SessionView { + authenticated: user_id.is_some(), + user_id, + refreshed, + session_id, + } +} + +#[cfg(any(feature = "extras-session", feature = "session"))] +async fn login(session: Session, Json(payload): Json) -> Json { + session.cycle_id().await; + session + .insert("user_id", &payload.user_id) + .await + .expect("serializing user_id into the session should succeed"); + session + .insert("refreshed", false) + .await + .expect("serializing refreshed flag into the session should succeed"); + + Json(session_view(&session).await) +} + +#[cfg(any(feature = "extras-session", feature = "session"))] +async fn me(session: Session) -> Json { + Json(session_view(&session).await) +} + +#[cfg(any(feature = "extras-session", feature = "session"))] +async fn refresh(session: Session) -> Json { + if session.contains("user_id").await { + session.cycle_id().await; + session + .insert("refreshed", true) + .await + .expect("serializing refreshed flag into the session should succeed"); + } + + Json(session_view(&session).await) +} + +#[cfg(any(feature = "extras-session", feature = "session"))] +async fn logout(session: Session) -> NoContent { + session.destroy().await; + NoContent +} + +#[cfg(any(feature = "extras-session", feature = "session"))] +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Starting session auth example..."); + println!(" -> POST http://127.0.0.1:3000/auth/login {{\"user_id\":\"demo-user\"}}"); + println!(" -> GET http://127.0.0.1:3000/auth/me"); + println!(" -> POST http://127.0.0.1:3000/auth/refresh"); + println!(" -> POST http://127.0.0.1:3000/auth/logout"); + + RustApi::new() + .layer(SessionLayer::new( + MemorySessionStore::new(), + SessionConfig::new() + .cookie_name("rustapi_auth") + .secure(false) + .ttl(Duration::from_secs(60 * 30)), + )) + .route("/auth/login", post(login)) + .route("/auth/me", get(me)) + .route("/auth/refresh", post(refresh)) + .route("/auth/logout", post(logout)) + .run("127.0.0.1:3000") + .await +} diff --git a/crates/rustapi-rs/examples/full_crud_api.rs b/crates/rustapi-rs/examples/full_crud_api.rs new file mode 100644 index 00000000..364f8f58 --- /dev/null +++ b/crates/rustapi-rs/examples/full_crud_api.rs @@ -0,0 +1,112 @@ +use rustapi_rs::prelude::*; +use std::collections::HashMap; +use std::sync::{atomic::{AtomicU64, Ordering}, Arc}; +use tokio::sync::RwLock; + +#[derive(Clone)] +struct AppState { + next_id: Arc, + todos: Arc>>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Schema)] +struct TodoItem { + id: u64, + title: String, + completed: bool, +} + +#[derive(Debug, Deserialize, Schema)] +struct CreateTodo { + title: String, +} + +#[derive(Debug, Deserialize, Schema)] +struct UpdateTodo { + title: Option, + completed: Option, +} + +#[derive(Debug, Serialize, Schema)] +struct TodoEnvelope { + found: bool, + item: Option, + message: Option, +} + +async fn list_todos(State(state): State) -> Json> { + let todos = state.todos.read().await; + let mut items: Vec<_> = todos.values().cloned().collect(); + items.sort_by_key(|todo| todo.id); + Json(items) +} + +async fn create_todo(State(state): State, Json(payload): Json) -> Created { + let id = state.next_id.fetch_add(1, Ordering::SeqCst); + let item = TodoItem { + id, + title: payload.title, + completed: false, + }; + + state.todos.write().await.insert(id, item.clone()); + Created(item) +} + +async fn get_todo(State(state): State, Path(id): Path) -> Json { + let item = state.todos.read().await.get(&id).cloned(); + Json(TodoEnvelope { + found: item.is_some(), + item, + message: Some(format!("Looked up todo {}", id)), + }) +} + +async fn update_todo( + State(state): State, + Path(id): Path, + Json(payload): Json, +) -> Json { + let mut todos = state.todos.write().await; + let item = todos.get_mut(&id).map(|todo| { + if let Some(title) = payload.title { + todo.title = title; + } + if let Some(completed) = payload.completed { + todo.completed = completed; + } + todo.clone() + }); + + Json(TodoEnvelope { + found: item.is_some(), + item, + message: Some(format!("Updated todo {}", id)), + }) +} + +async fn delete_todo(State(state): State, Path(id): Path) -> NoContent { + state.todos.write().await.remove(&id); + println!("Deleted todo {} (if it existed)", id); + NoContent +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Starting CRUD example..."); + println!(" -> GET http://127.0.0.1:3000/todos"); + println!(" -> POST http://127.0.0.1:3000/todos"); + println!(" -> GET http://127.0.0.1:3000/todos/1"); + println!(" -> PUT http://127.0.0.1:3000/todos/1"); + println!(" -> DELETE http://127.0.0.1:3000/todos/1"); + + RustApi::new() + .state(AppState { + next_id: Arc::new(AtomicU64::new(1)), + todos: Arc::new(RwLock::new(HashMap::new())), + }) + .route("/todos", get(list_todos).post(create_todo)) + .route("/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo)) + .run("127.0.0.1:3000") + .await +} diff --git a/crates/rustapi-rs/examples/jobs_api.rs b/crates/rustapi-rs/examples/jobs_api.rs new file mode 100644 index 00000000..ec3632c0 --- /dev/null +++ b/crates/rustapi-rs/examples/jobs_api.rs @@ -0,0 +1,136 @@ +#[cfg(not(any(feature = "extras-jobs", feature = "jobs")))] +fn main() { + eprintln!( + "Run this example with jobs support enabled:\n cargo run -p rustapi-rs --example jobs_api --features extras-jobs" + ); +} + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +use async_trait::async_trait; +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +use rustapi_rs::extras::jobs::{InMemoryBackend, Job, JobContext, JobQueue}; +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +use rustapi_rs::prelude::*; +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +use std::sync::{atomic::{AtomicU64, Ordering}, Arc}; + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +#[derive(Clone)] +struct AppState { + processed_jobs: Arc, + queue: JobQueue, +} + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +#[derive(Debug, Clone, Deserialize, Serialize, Schema)] +struct EmailJobData { + to: String, + subject: String, +} + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +#[derive(Clone)] +struct SendEmailJob { + processed_jobs: Arc, +} + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +#[async_trait] +impl Job for SendEmailJob { + const NAME: &'static str = "send_email"; + type Data = EmailJobData; + + async fn execute( + &self, + ctx: JobContext, + data: Self::Data, + ) -> std::result::Result<(), rustapi_rs::extras::jobs::JobError> { + println!( + "[job:{} attempt:{}] sending '{}' to {}", + ctx.job_id, ctx.attempt, data.subject, data.to + ); + self.processed_jobs.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +#[derive(Debug, Serialize, Schema)] +struct EnqueueResponse { + job_id: String, + queued: bool, +} + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +#[derive(Debug, Serialize, Schema)] +struct WorkerResponse { + processed: bool, + total_processed: u64, +} + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +async fn enqueue_email( + State(state): State, + Json(payload): Json, +) -> Created { + let job_id = state + .queue + .enqueue::(payload) + .await + .expect("enqueue should succeed for the in-memory backend"); + + Created(EnqueueResponse { + job_id, + queued: true, + }) +} + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +async fn process_next(State(state): State) -> Json { + let processed = state + .queue + .process_one() + .await + .expect("processing should succeed for the in-memory backend"); + + Json(WorkerResponse { + processed, + total_processed: state.processed_jobs.load(Ordering::SeqCst), + }) +} + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +async fn queue_stats(State(state): State) -> Json { + Json(WorkerResponse { + processed: false, + total_processed: state.processed_jobs.load(Ordering::SeqCst), + }) +} + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Starting jobs example..."); + println!(" -> POST http://127.0.0.1:3000/jobs/email"); + println!(" -> POST http://127.0.0.1:3000/jobs/process-next"); + println!(" -> GET http://127.0.0.1:3000/jobs/stats"); + + let processed_jobs = Arc::new(AtomicU64::new(0)); + let queue = JobQueue::new(InMemoryBackend::new()); + queue + .register_job(SendEmailJob { + processed_jobs: processed_jobs.clone(), + }) + .await; + + RustApi::new() + .state(AppState { + processed_jobs, + queue, + }) + .route("/jobs/email", post(enqueue_email)) + .route("/jobs/process-next", post(process_next)) + .route("/jobs/stats", get(queue_stats)) + .run("127.0.0.1:3000") + .await +} diff --git a/crates/rustapi-rs/examples/streaming_api.rs b/crates/rustapi-rs/examples/streaming_api.rs new file mode 100644 index 00000000..109ce289 --- /dev/null +++ b/crates/rustapi-rs/examples/streaming_api.rs @@ -0,0 +1,48 @@ +use rustapi_rs::prelude::*; +use std::convert::Infallible; + +#[derive(Debug, Serialize, Schema)] +struct ProgressUpdate { + step: u32, + message: String, +} + +async fn progress_feed() -> Sse>> { + let events = vec![ + Ok::<_, Infallible>(SseEvent::json_data(&ProgressUpdate { + step: 1, + message: "queued".to_string(), + }) + .expect("json data should serialize") + .event("progress") + .id("1")), + Ok::<_, Infallible>(SseEvent::json_data(&ProgressUpdate { + step: 2, + message: "processing".to_string(), + }) + .expect("json data should serialize") + .event("progress") + .id("2")), + Ok::<_, Infallible>(SseEvent::json_data(&ProgressUpdate { + step: 3, + message: "done".to_string(), + }) + .expect("json data should serialize") + .event("complete") + .id("3") + .retry(2_000)), + ]; + + sse_from_iter(events).keep_alive(KeepAlive::new()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + println!("Starting streaming example..."); + println!(" -> GET http://127.0.0.1:3000/events"); + + RustApi::new() + .route("/events", get(progress_feed)) + .run("127.0.0.1:3000") + .await +} diff --git a/crates/rustapi-rs/src/lib.rs b/crates/rustapi-rs/src/lib.rs index ede94c1e..9ba5d389 100644 --- a/crates/rustapi-rs/src/lib.rs +++ b/crates/rustapi-rs/src/lib.rs @@ -28,11 +28,14 @@ pub mod core { route, serve_dir, sse_response, ApiError, AsyncValidatedJson, Body, BodyLimitLayer, BodyStream, BodyVariant, ClientIp, Created, CursorPaginate, CursorPaginated, Environment, Extension, FieldError, FromRequest, FromRequestParts, Handler, HandlerService, HeaderValue, - Headers, Html, IntoResponse, Json, KeepAlive, MethodRouter, Multipart, MultipartConfig, - MultipartField, NoContent, Paginate, Paginated, Path, Query, Redirect, Request, RequestId, - RequestIdLayer, Response, ResponseBody, Result, Route, RouteHandler, RouteMatch, Router, - RustApi, RustApiConfig, Sse, SseEvent, State, StaticFile, StaticFileConfig, StatusCode, - StreamBody, TracingLayer, Typed, TypedPath, UploadedFile, ValidatedJson, WithStatus, + Headers, HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthEndpointConfig, + HealthStatus, Html, IntoResponse, Json, KeepAlive, MethodRouter, Multipart, + MultipartConfig, MultipartField, NoContent, Paginate, Paginated, Path, Query, Redirect, + ProductionDefaultsConfig, Request, RequestId, RequestIdLayer, Response, ResponseBody, + Result, Route, RouteHandler, RouteMatch, Router, RustApi, RustApiConfig, Sse, SseEvent, + State, StaticFile, sse_from_iter, + StaticFileConfig, StatusCode, StreamBody, TracingLayer, Typed, TypedPath, UploadedFile, + ValidatedJson, WithStatus, }; pub use rustapi_core::get_environment; @@ -186,6 +189,35 @@ pub mod extras { pub mod replay { pub use rustapi_extras::replay; } + + #[cfg(any(feature = "extras-oauth2-client", feature = "oauth2-client"))] + pub mod oauth2 { + pub use rustapi_extras::oauth2; + pub use rustapi_extras::{ + AuthorizationRequest, CsrfState, OAuth2Client, OAuth2Config, PkceVerifier, Provider, + TokenError, TokenResponse, + }; + } + + #[cfg(any(feature = "extras-session", feature = "session"))] + pub mod session { + pub use rustapi_extras::session; + pub use rustapi_extras::{ + MemorySessionStore, Session, SessionConfig, SessionError, SessionLayer, + SessionRecord, SessionStore, + }; + + #[cfg(any(feature = "extras-session-redis", feature = "session-redis"))] + pub use rustapi_extras::RedisSessionStore; + } + + #[cfg(any(feature = "extras-jobs", feature = "jobs"))] + pub mod jobs { + pub use rustapi_jobs::{ + EnqueueOptions, InMemoryBackend, Job, JobBackend, JobContext, JobError, JobQueue, + JobRequest, + }; + } } // Legacy root module aliases. @@ -269,6 +301,30 @@ pub use rustapi_extras::structured_logging; #[cfg(any(feature = "extras-timeout", feature = "timeout"))] pub use rustapi_extras::timeout; +#[cfg(any(feature = "extras-oauth2-client", feature = "oauth2-client"))] +pub use rustapi_extras::oauth2; +#[cfg(any(feature = "extras-oauth2-client", feature = "oauth2-client"))] +pub use rustapi_extras::{ + AuthorizationRequest, CsrfState, OAuth2Client, OAuth2Config, PkceVerifier, Provider, + TokenError, TokenResponse, +}; + +#[cfg(any(feature = "extras-session", feature = "session"))] +pub use rustapi_extras::session; +#[cfg(any(feature = "extras-session", feature = "session"))] +pub use rustapi_extras::{ + MemorySessionStore, Session, SessionConfig, SessionError, SessionLayer, SessionRecord, + SessionStore, +}; + +#[cfg(any(feature = "extras-session-redis", feature = "session-redis"))] +pub use rustapi_extras::RedisSessionStore; + +#[cfg(any(feature = "extras-jobs", feature = "jobs"))] +pub use rustapi_jobs::{ + EnqueueOptions, InMemoryBackend, Job, JobBackend, JobContext, JobError, JobQueue, JobRequest, +}; + /// Prelude module: `use rustapi_rs::prelude::*`. pub mod prelude { pub use crate::core::EventBus; @@ -276,12 +332,14 @@ pub mod prelude { pub use crate::core::{ delete, delete_route, get, get_route, patch, patch_route, post, post_route, put, put_route, route, serve_dir, sse_response, ApiError, AsyncValidatedJson, Body, BodyLimitLayer, - ClientIp, Created, CursorPaginate, CursorPaginated, Extension, HeaderValue, Headers, Html, - IntoResponse, Json, KeepAlive, Multipart, MultipartConfig, MultipartField, NoContent, - Paginate, Paginated, Path, Query, Redirect, Request, RequestId, RequestIdLayer, Response, - Result, Route, Router, RustApi, RustApiConfig, Sse, SseEvent, State, StaticFile, - StaticFileConfig, StatusCode, StreamBody, TracingLayer, Typed, TypedPath, UploadedFile, - ValidatedJson, WithStatus, + ClientIp, Created, CursorPaginate, CursorPaginated, Extension, HeaderValue, Headers, + HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthEndpointConfig, HealthStatus, + Html, IntoResponse, Json, KeepAlive, Multipart, MultipartConfig, MultipartField, + NoContent, Paginate, Paginated, Path, ProductionDefaultsConfig, Query, Redirect, Request, + RequestId, RequestIdLayer, Response, Result, Route, Router, RustApi, RustApiConfig, Sse, + SseEvent, State, StaticFile, StaticFileConfig, StatusCode, StreamBody, TracingLayer, + Typed, TypedPath, UploadedFile, ValidatedJson, WithStatus, + sse_from_iter, }; #[cfg(any(feature = "core-compression", feature = "compression"))] @@ -321,6 +379,27 @@ pub mod prelude { #[cfg(any(feature = "extras-sqlx", feature = "sqlx"))] pub use crate::{convert_sqlx_error, SqlxErrorExt}; + #[cfg(any(feature = "extras-oauth2-client", feature = "oauth2-client"))] + pub use crate::{ + AuthorizationRequest, CsrfState, OAuth2Client, OAuth2Config, PkceVerifier, Provider, + TokenError, TokenResponse, + }; + + #[cfg(any(feature = "extras-session", feature = "session"))] + pub use crate::{ + MemorySessionStore, Session, SessionConfig, SessionError, SessionLayer, SessionRecord, + SessionStore, + }; + + #[cfg(any(feature = "extras-session-redis", feature = "session-redis"))] + pub use crate::RedisSessionStore; + + #[cfg(any(feature = "extras-jobs", feature = "jobs"))] + pub use crate::{ + EnqueueOptions, InMemoryBackend, Job, JobBackend, JobContext, JobError, JobQueue, + JobRequest, + }; + #[cfg(any(feature = "protocol-toon", feature = "toon"))] pub use crate::protocol::toon::{AcceptHeader, LlmResponse, Negotiate, OutputFormat, Toon}; diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 6f51d674..693f05b2 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -638,6 +638,43 @@ async fn test_create_user() { ### Health Check +RustAPI now ships standard health probes out of the box: + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { + RustApi::auto() + .health_endpoints() + .run("127.0.0.1:8080") + .await +} +``` + +This enables: +- `/health` — aggregate health report +- `/ready` — readiness probe for orchestrators +- `/live` — lightweight liveness probe + +If you want a stronger production-ready baseline from day one, use: + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { + RustApi::auto() + .production_defaults("hello-api") + .run("127.0.0.1:8080") + .await +} +``` + +This installs request IDs, tracing middleware, and probe endpoints together. + +If you want a custom probe, you can still define one manually: + ```rust #[rustapi_rs::get("/health")] async fn health() -> &'static str { diff --git a/docs/PERFORMANCE_BENCHMARKS.md b/docs/PERFORMANCE_BENCHMARKS.md new file mode 100644 index 00000000..7008d5d0 --- /dev/null +++ b/docs/PERFORMANCE_BENCHMARKS.md @@ -0,0 +1,130 @@ +# Performance Benchmarks + +This document is the **authoritative source** for public RustAPI performance claims. + +If `README.md`, cookbook pages, release notes, or other user-facing docs mention throughput or latency numbers, they should either: + +- link to this file, or +- explicitly identify themselves as a historical point-in-time snapshot. + +## Current status + +RustAPI has benchmark automation entry points in the repository, but the public docs should avoid treating older point-in-time numbers as the current universal baseline. + +Today that means: + +- historical benchmark numbers in older release notes are kept for historical context, +- current public docs should prefer qualitative performance guidance plus this benchmark source, +- new absolute claims should only be published here after a fresh, reproducible run with raw output attached. + +## Canonical benchmark entry points + +### Local + +Use the repository benchmark script: + +```powershell +./scripts/bench.ps1 +``` + +This currently runs: + +```powershell +cargo bench --workspace +cargo run -p rustapi-core --example perf_snapshot --release +``` + +### CI + +The repository also includes a manual benchmark workflow: + +- `.github/workflows/benchmark.yml` + +That workflow runs `cargo bench --workspace` and uploads the raw output as `benchmark_results.txt`. + +It also runs `cargo run -p rustapi-core --example perf_snapshot --release` and uploads the raw output as `perf_snapshot.txt`. + +## Latest validated snapshot + +The following snapshot was generated in this session from a real local run. + +### Environment + +- **Date**: 2026-03-08 +- **CPU**: AMD Ryzen 7 4800H with Radeon Graphics +- **OS**: Microsoft Windows 10 Home +- **Rust**: `rustc 1.91.0 (f8297e351 2025-10-28)` +- **Cargo**: `cargo 1.91.0 (ea2d97820 2025-10-10)` +- **Command**: `cargo run -p rustapi-core --example perf_snapshot --release` +- **Warmup iterations**: `1000` +- **Measured iterations**: `10000` +- **Feature context**: `rustapi-core` default features (`swagger-ui`, `tracing`) +- **Workload**: synthetic in-process request pipeline benchmark for a static `GET /hello` route + +### Latency outputs and feature-cost matrix + +| Scenario | Execution path | Features | Req/s | Mean (µs) | p50 (µs) | p95 (µs) | p99 (µs) | +|---|---|---|---:|---:|---:|---:|---:| +| `baseline` | ultra fast | no middleware, no interceptors | 1,317,349 | 0.64 | 0.60 | 0.90 | 1.90 | +| `request_interceptor` | fast | 1 request interceptor | 1,174,508 | 0.73 | 0.70 | 1.00 | 2.00 | +| `request_response_interceptors` | fast | 1 request + 1 response interceptor | 1,230,406 | 0.71 | 0.60 | 0.70 | 2.00 | +| `middleware_only` | full | 1 middleware layer | 670,916 | 1.36 | 1.10 | 2.40 | 3.40 | +| `full_stack_minimal` | full | 1 middleware + 1 request + 1 response interceptor | 632,003 | 1.45 | 1.30 | 2.50 | 2.90 | +| `request_id_layer` | full | `RequestIdLayer` | 348,754 | 2.71 | 2.50 | 3.80 | 4.80 | + +### Relative overhead vs baseline + +| Scenario | Req/s delta | p99 delta | +|---|---:|---:| +| `baseline` | +0.00% | +0.00% | +| `request_interceptor` | -10.84% | +5.26% | +| `request_response_interceptors` | -6.60% | +5.26% | +| `middleware_only` | -49.07% | +78.95% | +| `full_stack_minimal` | -52.02% | +52.63% | +| `request_id_layer` | -73.53% | +152.63% | + +## Execution path comparison + +This snapshot confirms the intended three-tier execution model: + +- **Ultra fast** path remains the cheapest route for static handler execution with no middleware or interceptors. +- **Fast** path adds modest overhead for interceptors without dropping into the full middleware stack. +- **Full** path is measurably more expensive, especially once real middleware such as `RequestIdLayer` is added. + +Because this benchmark is synthetic and in-process, treat it as a **framework pipeline cost snapshot**, not as an end-to-end HTTP server benchmark or a cross-framework comparison. + +## Publishing rules for new benchmark claims + +Before adding or updating public performance numbers, capture all of the following in the benchmark record: + +- hardware (CPU, RAM) +- OS +- Rust toolchain version +- benchmark command +- scenario/workload description +- enabled feature flags +- request rate / throughput metric +- latency distribution, including $p50$, $p95$, and $p99$ +- memory footprint, if reported + +If a claim does not include enough metadata to be reproduced, it should not be treated as canonical. + +## Historical notes + +Some existing changelog entries include benchmark numbers from older runs. Those are still useful as release-history context, but they are **not** the canonical current baseline unless they are linked back from this document. + +In particular, the `0.1.202` changelog entry records a Windows 11 / Ryzen 9 5900X snapshot. Treat it as a historical benchmark note, not the current authoritative cross-framework comparison. + +## Guidance for other docs + +- `README.md` should link here instead of embedding standalone req/s claims. +- Performance-focused cookbook pages should explain **how** RustAPI stays fast and point here for benchmark publication policy. +- Release notes may summarize benchmark-related improvements, but they should cite this document for the benchmark source of truth. + +## Still intentionally open + +The following performance work remains open in `tasks.md`: + +- broader end-to-end benchmark scenarios beyond the synthetic in-process pipeline snapshot + +When additional benchmark families are ready, add them here first and then link outward. \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index ab5a2da6..3cc50abb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,9 @@ Welcome to the RustAPI documentation! | [Features](FEATURES.md) | Complete feature reference | | [Philosophy](PHILOSOPHY.md) | Design principles and decisions | | [Architecture](ARCHITECTURE.md) | Internal structure deep dive | +| [Performance Benchmarks](PERFORMANCE_BENCHMARKS.md) | Authoritative source for benchmark methodology and published claims | +| [Recommended Production Baseline](PRODUCTION_BASELINE.md) | Opinionated starting point for production services | +| [Production Checklist](PRODUCTION_CHECKLIST.md) | Rollout-ready operational checklist | ## What is RustAPI? @@ -59,12 +62,19 @@ Visit `http://localhost:8080/docs` for auto-generated Swagger UI. ## Examples -See the [examples](../examples/) directory: -- `hello-world` — Minimal example -- `crud-api` — Full CRUD operations -- `auth-api` — JWT authentication -- `toon-api` — LLM-optimized responses -- `proof-of-concept` — Complete feature showcase +See [`crates/rustapi-rs/examples/README.md`](../crates/rustapi-rs/examples/README.md) for the current in-repository example index. + +Current examples in this repository: +- `typed_path_poc` — Typed path registration and URI generation +- `status_demo` — Automatic status page demo with live traffic/error generation + +## Production Guides + +- [Recommended Production Baseline](PRODUCTION_BASELINE.md) +- [Production Checklist](PRODUCTION_CHECKLIST.md) +- [Cookbook: Graceful Shutdown](cookbook/src/recipes/graceful_shutdown.md) +- [Cookbook: Deployment](cookbook/src/recipes/deployment.md) +- [Cookbook: Observability](cookbook/src/recipes/observability.md) ## License diff --git a/docs/cookbook/src/SUMMARY.md b/docs/cookbook/src/SUMMARY.md index 998b872e..05e4ce17 100644 --- a/docs/cookbook/src/SUMMARY.md +++ b/docs/cookbook/src/SUMMARY.md @@ -27,18 +27,27 @@ - [rustapi-testing: The Auditor](crates/rustapi_testing.md) - [cargo-rustapi: The Architect](crates/cargo_rustapi.md) +- [Part III.5: Reference](reference/README.md) + - [Macro Attribute Reference](reference/macro_attributes.md) + - [Part IV: Recipes](recipes/README.md) - [Creating Resources](recipes/crud_resource.md) - [Pagination & HATEOAS](recipes/pagination.md) - [OpenAPI & Schemas](recipes/openapi_refs.md) - [JWT Authentication](recipes/jwt_auth.md) + - [Session-Based Authentication](recipes/session_auth.md) - [OAuth2 Client](recipes/oauth2_client.md) + - [OIDC & OAuth2 in Production](recipes/oidc_oauth2_production.md) - [CSRF Protection](recipes/csrf_protection.md) - [Database Integration](recipes/db_integration.md) - [Testing & Mocking](recipes/testing.md) - [File Uploads](recipes/file_uploads.md) - [Background Jobs](recipes/background_jobs.md) + - [Custom Extractors](recipes/custom_extractors.md) - [Custom Middleware](recipes/custom_middleware.md) + - [Error Handling](recipes/error_handling.md) + - [Axum -> RustAPI Migration](recipes/axum_migration.md) + - [Actix-web -> RustAPI Migration](recipes/actix_migration.md) - [Advanced Middleware](recipes/advanced_middleware.md) - [Real-time Chat](recipes/websockets.md) - [Server-Side Rendering (SSR)](recipes/server_side_rendering.md) @@ -46,6 +55,8 @@ - [Production Tuning](recipes/high_performance.md) - [Response Compression](recipes/compression.md) - [Resilience Patterns](recipes/resilience.md) + - [Observability](recipes/observability.md) + - [Middleware Debugging](recipes/middleware_debugging.md) - [Graceful Shutdown](recipes/graceful_shutdown.md) - [Audit Logging](recipes/audit_logging.md) - [Time-Travel Debugging (Replay)](recipes/replay.md) diff --git a/docs/cookbook/src/concepts/performance.md b/docs/cookbook/src/concepts/performance.md index ae9f6929..cd9fa176 100644 --- a/docs/cookbook/src/concepts/performance.md +++ b/docs/cookbook/src/concepts/performance.md @@ -73,33 +73,41 @@ struct AppState { ## Benchmarking -Performance is not a guessing game. Below are results from our internal benchmarks on reference hardware. - -### Comparative Benchmarks - -| Framework | Requests/sec | Latency (avg) | Memory | -|-----------|--------------|---------------|--------| -| **RustAPI** | **~185,000** | **~0.54ms** | **~8MB** | -| **RustAPI + core-simd-json** | **~220,000** | **~0.45ms** | **~8MB** | -| Actix-web | ~178,000 | ~0.56ms | ~10MB | -| Axum | ~165,000 | ~0.61ms | ~12MB | -| Rocket | ~95,000 | ~1.05ms | ~15MB | -| FastAPI (Python) | ~12,000 | ~8.3ms | ~45MB | - -
-🔬 Test Configuration - -- **Hardware**: Intel i7-12700K, 32GB RAM -- **Method**: `wrk -t12 -c400 -d30s http://127.0.0.1:8080/api/users` -- **Scenario**: JSON serialization of 100 user objects -- **Build**: `cargo build --release` - -Results may vary based on hardware and workload. Run your own benchmarks: -```bash -cd benches -./run_benchmarks.ps1 +Performance is not a guessing game, but it is very easy to misquote stale numbers. + +For that reason, RustAPI keeps its benchmark publication policy and canonical claims in [`docs/PERFORMANCE_BENCHMARKS.md`](../../../PERFORMANCE_BENCHMARKS.md). + +Use that document for: + +- the current benchmark source of truth, +- publication rules for new public claims, +- local and CI benchmark entry points, and +- historical-vs-current benchmark context. + +### Run benchmarks locally + +From the repository root: + +```powershell +./scripts/bench.ps1 ``` -
+ +That currently executes `cargo bench --workspace`. + +### CI benchmark path + +The repository also includes `.github/workflows/benchmark.yml`, which runs the same benchmark command and uploads the raw benchmark output as an artifact. + +### What to publish with benchmark results + +Whenever you publish new numbers, include at minimum: + +- hardware and OS +- Rust toolchain version +- command and workload description +- enabled feature flags +- throughput plus $p50$, $p95$, and $p99$ latency +- memory usage when available ### Why So Fast? diff --git a/docs/cookbook/src/learning/curriculum.md b/docs/cookbook/src/learning/curriculum.md index f95e1d6a..7563d58f 100644 --- a/docs/cookbook/src/learning/curriculum.md +++ b/docs/cookbook/src/learning/curriculum.md @@ -104,7 +104,7 @@ Create a `POST /register` endpoint that accepts a JSON body `{"username": "...", ### Module 5.5: Error Handling - **Prerequisites:** Module 5. -- **Reading:** [Error Handling](../concepts/errors.md). +- **Reading:** [Error Handling](../recipes/error_handling.md). - **Task:** Create a custom `ApiError` enum and implement `IntoResponse`. Return robust error messages. - **Expected Output:** `GET /users/999` returns `404 Not Found` with a structured JSON error body. - **Pitfalls:** Exposing internal database errors (like SQL strings) to the client. diff --git a/docs/cookbook/src/recipes/README.md b/docs/cookbook/src/recipes/README.md index 1d7520b7..5314cc86 100644 --- a/docs/cookbook/src/recipes/README.md +++ b/docs/cookbook/src/recipes/README.md @@ -15,18 +15,27 @@ Each recipe follows a simple structure: - [Pagination & HATEOAS](pagination.md) - [OpenAPI & Schemas](openapi_refs.md) - [JWT Authentication](jwt_auth.md) +- [Session-Based Authentication](session_auth.md) +- [OAuth2 Client](oauth2_client.md) +- [OIDC & OAuth2 in Production](oidc_oauth2_production.md) - [CSRF Protection](csrf_protection.md) - [Database Integration](db_integration.md) - [Testing & Mocking](testing.md) - [File Uploads](file_uploads.md) - [Background Jobs](background_jobs.md) +- [Custom Extractors](custom_extractors.md) - [Custom Middleware](custom_middleware.md) +- [Error Handling](error_handling.md) +- [Axum -> RustAPI Migration](axum_migration.md) +- [Actix-web -> RustAPI Migration](actix_migration.md) - [Real-time Chat](websockets.md) - [Server-Side Rendering (SSR)](server_side_rendering.md) - [AI Integration (TOON)](ai_integration.md) - [Production Tuning](high_performance.md) - [Response Compression](compression.md) - [Resilience Patterns](resilience.md) +- [Observability](observability.md) +- [Middleware Debugging](middleware_debugging.md) - [Graceful Shutdown](graceful_shutdown.md) - [Time-Travel Debugging (Replay)](replay.md) - [Deployment](deployment.md) diff --git a/docs/cookbook/src/recipes/actix_migration.md b/docs/cookbook/src/recipes/actix_migration.md new file mode 100644 index 00000000..ba2e8b0e --- /dev/null +++ b/docs/cookbook/src/recipes/actix_migration.md @@ -0,0 +1,378 @@ +# Actix-web -> RustAPI Migration Guide + +If you already know Actix-web, RustAPI will feel familiar in a few core areas while removing some of the ceremony around route registration and OpenAPI integration. + +This guide focuses on the migration path for the most common Actix-web patterns: + +- handlers and extractors +- app state +- route registration +- middleware +- testing +- OpenAPI/documentation + +## What stays familiar + +The good news first: the everyday endpoint concepts map cleanly. + +| Actix-web concept | RustAPI equivalent | Notes | +|---|---|---| +| `web::Data` | `State` | shared application state | +| `web::Path` | `Path` | typed path extraction | +| `web::Query` | `Query` | typed query extraction | +| `web::Json` | `Json` | JSON body extraction | +| `App::route()` / `.service()` | `RustApi::route()` / route macros | both support explicit routing | +| `wrap(...)` middleware | `.layer(...)` | middleware stack support | +| `actix_web::test` helpers | `rustapi_testing::TestClient` | in-memory HTTP-style tests | + +The biggest differences are: + +1. RustAPI encourages application code to import from the `rustapi-rs` facade. +2. RustAPI can auto-discover macro-annotated routes with `RustApi::auto()`. +3. OpenAPI support is designed to live close to handlers instead of being bolted on later. + +## 1. Imports: switch to the facade + +Actix-web applications usually import directly from `actix_web`. + +In RustAPI, start from the public facade: + +```rust +use rustapi_rs::prelude::*; +``` + +That keeps your application code aligned with RustAPI’s stable public surface instead of internal implementation crates. + +## 2. Basic handlers migrate directly + +### Actix-web + +```rust +use actix_web::{get, web, Responder}; +use serde::Serialize; + +#[derive(Serialize)] +struct User { + id: i64, + name: String, +} + +#[get("/users/{id}")] +async fn get_user(id: web::Path) -> impl Responder { + let id = id.into_inner(); + + web::Json(User { + id, + name: "Alice".into(), + }) +} +``` + +### RustAPI + +```rust +use rustapi_rs::prelude::*; + +#[derive(Serialize, Schema)] +struct User { + id: i64, + name: String, +} + +#[rustapi_rs::get("/users/{id}")] +async fn get_user(Path(id): Path) -> Json { + Json(User { + id, + name: "Alice".into(), + }) +} +``` + +### Migration note + +- The path syntax is already `{id}` in both ecosystems, so that part stays pleasantly boring. +- Add `Schema` when the type should appear in generated OpenAPI docs. +- RustAPI handler signatures stay compact and keep extractor types explicit. + +## 3. App bootstrap: `App` -> `RustApi` + +### Actix-web + +```rust +use actix_web::{web, App, HttpServer}; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + HttpServer::new(|| { + App::new().route("/users/{id}", web::get().to(get_user)) + }) + .bind(("127.0.0.1", 8080))? + .run() + .await +} +``` + +### RustAPI + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { + RustApi::new() + .route("/users/{id}", get(get_user)) + .run("127.0.0.1:8080") + .await +} +``` + +### Migration note + +- `RustApi::new()` is the main application entry point. +- `RustApi::route()` is the closest equivalent to explicit Actix route registration. +- For macro-annotated handlers, `RustApi::auto()` can remove repetitive wiring. + +## 4. Auto-registration can replace repetitive `.service(...)` + +If your Actix-web app registers many handlers manually, RustAPI can let the route macros do more of the work. + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::get("/health")] +async fn health() -> &'static str { + "ok" +} + +#[rustapi_rs::get("/users/{id}")] +async fn get_user(Path(id): Path) -> Json { + Json(id) +} + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { + RustApi::auto().run("127.0.0.1:8080").await +} +``` + +This is where a wall of `.service(...)` calls starts to quietly disappear. Your future diff reviews may even send a thank-you card. + +## 5. State injection: `web::Data` -> `State` + +### Actix-web + +```rust +use actix_web::{web, App, HttpServer, Responder}; +use std::sync::Arc; + +#[derive(Clone)] +struct AppState { + db: Arc, +} + +async fn users(state: web::Data) -> impl Responder { + state.db.to_string() +} + +let state = AppState { + db: Arc::new("db".into()), +}; +``` + +### RustAPI + +```rust +use rustapi_rs::prelude::*; +use std::sync::Arc; + +#[derive(Clone)] +struct AppState { + db: Arc, +} + +#[rustapi_rs::get("/users")] +async fn users(State(state): State) -> String { + state.db.to_string() +} + +let app = RustApi::new() + .state(AppState { + db: Arc::new("db".into()), + }) + .route("/users", get(users)); +``` + +### Migration note + +- Keep shared state `Clone + Send + Sync`. +- Cheap-to-clone `Arc<_>` fields remain the right pattern for shared dependencies. +- Instead of wrapping state in `web::Data`, RustAPI stores the state directly and extracts it with `State`. + +## 6. Extractor migration map + +| Actix-web | RustAPI | Notes | +|---|---|---| +| `web::Data` | `State` | shared app state | +| `web::Path` | `Path` | typed path extraction | +| `web::Query` | `Query` | typed query extraction | +| `web::Json` | `Json` | body extraction | +| custom request extractor | `FromRequestParts` / `FromRequest` | choose based on body usage | + +### Important RustAPI rule + +Body-consuming extractors such as `Json`, `Body`, `ValidatedJson`, `AsyncValidatedJson`, and `Multipart` must be the **last** handler parameter. + +```rust +#[rustapi_rs::post("/users/{id}")] +async fn update_user( + State(_state): State, + Path(_id): Path, + Json(_body): Json, +) -> Result<()> { + Ok(()) +} +``` + +## 7. Middleware: `wrap(...)` mindset, RustAPI entry point + +Actix-web middleware and RustAPI middleware share the same big-picture mental model: requests go in, responses come out, and the middleware stack wraps the handler. + +Apply middleware with: + +```rust,ignore +RustApi::new() + .layer(RequestIdLayer::new()) + .layer(TracingLayer::new()) + .route("/users", get(users)); +``` + +### Migration note + +- Use `.layer(...)` for full middleware wrapping behavior. +- For lightweight request/response transformations, prefer interceptors when they are sufficient; they are cheaper than full middleware. +- Middleware layering order matters, so keep observability/auth/retry ordering intentional. + +## 8. Error handling becomes more uniform + +Actix-web often leans on `ResponseError`, `HttpResponse`, or custom response builders. RustAPI keeps the same flexibility, but the common path is `ApiError`. + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::get("/users/{id}")] +#[rustapi_rs::errors(404 = "User not found")] +async fn get_user(Path(id): Path) -> Result> { + if id == 0 { + return Err(ApiError::not_found("User not found")); + } + + Ok(Json(User { + id, + name: "Alice".into(), + })) +} +``` + +### Migration note + +- `#[errors(...)]` documents the OpenAPI response surface. +- Your handler still needs to return the matching runtime error. +- In production, RustAPI masks internal 5xx details automatically. + +## 9. OpenAPI moves closer to the handler + +In Actix-web projects, OpenAPI is often layered in through separate crates and extra registration code. + +In RustAPI, it becomes part of the main handler workflow: + +- derive `Schema` for DTOs +- annotate handlers with `#[get]`, `#[post]`, and friends +- optionally add `#[tag]`, `#[summary]`, `#[description]`, `#[param]`, and `#[errors]` +- serve docs through the app configuration + +```rust +#[derive(Serialize, Schema)] +struct User { + id: i64, + name: String, +} + +#[rustapi_rs::get("/users/{id}")] +#[rustapi_rs::tag("Users")] +#[rustapi_rs::summary("Get user by ID")] +#[rustapi_rs::errors(404 = "User not found")] +async fn get_user(Path(id): Path) -> Result> { + Ok(Json(User { + id, + name: "Alice".into(), + })) +} +``` + +Ordinary path and query parameters are inferred into OpenAPI automatically, so `#[param(...)]` is mainly for path-parameter schema overrides. + +## 10. Testing migration: `actix_web::test` -> `TestClient` + +### RustAPI test style + +```rust +use rustapi_rs::prelude::*; +use rustapi_testing::TestClient; + +#[rustapi_rs::get("/hello")] +async fn hello() -> &'static str { + "hello" +} + +#[tokio::test] +async fn test_hello() { + let app = RustApi::new().route("/hello", get(hello)); + let client = TestClient::new(app); + + let response = client.get("/hello").send().await; + + assert_eq!(response.status(), 200); +} +``` + +### Migration note + +- `TestClient` exercises the application in memory without binding a socket. +- This is a good replacement for many Actix integration tests that currently build `App` instances plus test harness glue. + +## 11. Practical migration checklist + +Use this order for a low-drama migration: + +1. Replace handler imports with `use rustapi_rs::prelude::*` on the RustAPI side. +2. Port shared dependencies from `web::Data` to `State`. +3. Convert handlers one endpoint at a time. +4. Add `Schema` derives to DTOs that should appear in OpenAPI. +5. Replace repetitive `.service(...)` registration with route macros and `RustApi::auto()` when it reduces boilerplate. +6. Port middleware selectively instead of all at once. +7. Replace Actix test harness setup with `TestClient` where it simplifies coverage. +8. Add production defaults, tracing, and health probes once the endpoint layer is stable. + +## 12. Mental model shift + +### Actix-web mindset + +- build an `App` +- register routes and services explicitly +- add middleware with `wrap(...)` +- extend docs/testing with adjacent tooling + +### RustAPI mindset + +- write handler-first code +- annotate routes directly +- let `RustApi::auto()` discover them when useful +- keep docs and route metadata close to handlers + +## Related reading + +- [Macro Attribute Reference](../reference/macro_attributes.md) +- [Custom Extractors](custom_extractors.md) +- [Error Handling](error_handling.md) +- [Middleware Debugging](middleware_debugging.md) +- [Recommended Production Baseline](../../../PRODUCTION_BASELINE.md) diff --git a/docs/cookbook/src/recipes/axum_migration.md b/docs/cookbook/src/recipes/axum_migration.md new file mode 100644 index 00000000..802bda84 --- /dev/null +++ b/docs/cookbook/src/recipes/axum_migration.md @@ -0,0 +1,361 @@ +# Axum -> RustAPI Migration Guide + +If you already know Axum, RustAPI will feel familiar in the right places and pleasantly less repetitive in a few others. + +This guide focuses on the migration path for the most common Axum patterns: + +- handlers and extractors +- app state +- route registration +- middleware +- testing +- OpenAPI/documentation + +## What stays familiar + +The good news first: most everyday handler code barely changes. + +| Axum concept | RustAPI equivalent | Notes | +|---|---|---| +| `State` | `State` | same mental model | +| `Path` | `Path` | same purpose | +| `Query` | `Query` | same purpose | +| `Json` | `Json` | same purpose | +| `Router::route()` | `RustApi::route()` | similar registration flow | +| tower layers | `.layer(...)` | middleware stack support | +| integration testing with service/router | `TestClient` | in-memory, ergonomic | + +The biggest differences are: + +1. RustAPI encourages using `rustapi-rs` as a stable facade. +2. RustAPI can auto-discover macro-annotated routes with `RustApi::auto()`. +3. OpenAPI support is built directly into the framework flow. + +## 1. Imports: switch to the facade + +In Axum projects, imports are often spread across `axum`, `tower`, and OpenAPI add-ons. + +In RustAPI, start from the facade: + +```rust +use rustapi_rs::prelude::*; +``` + +That keeps your application code pinned to the public API surface instead of internal crates. + +## 2. Basic handlers migrate almost directly + +### Axum + +```rust +use axum::{extract::Path, Json}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +struct User { + id: i64, + name: String, +} + +async fn get_user(Path(id): Path) -> Json { + Json(User { + id, + name: "Alice".into(), + }) +} +``` + +### RustAPI + +```rust +use rustapi_rs::prelude::*; + +#[derive(Serialize, Schema)] +struct User { + id: i64, + name: String, +} + +#[rustapi_rs::get("/users/{id}")] +async fn get_user(Path(id): Path) -> Json { + Json(User { + id, + name: "Alice".into(), + }) +} +``` + +### Migration note + +- The extractor shape is essentially the same. +- Add `Schema` when you want the type represented in generated OpenAPI docs. +- RustAPI route macros use `"/users/{id}"` path syntax. + +## 3. App bootstrap: `Router` -> `RustApi` + +### Axum + +```rust +use axum::{routing::get, Router}; + +let app = Router::new().route("/users/:id", get(get_user)); +``` + +### RustAPI + +```rust +use rustapi_rs::prelude::*; + +let app = RustApi::new().route("/users/{id}", get(get_user)); +``` + +### Migration note + +- The conceptual shape is the same. +- Path parameters use `{id}` instead of `:id`. +- If you annotate handlers with route macros, you can often skip manual registration and use `RustApi::auto()`. + +## 4. Auto-registration can replace manual route wiring + +This is one of the biggest quality-of-life upgrades when moving from Axum. + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::get("/health")] +async fn health() -> &'static str { + "ok" +} + +#[rustapi_rs::get("/users/{id}")] +async fn get_user(Path(id): Path) -> Json { + Json(id) +} + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { + RustApi::auto().run("127.0.0.1:8080").await +} +``` + +If your Axum app has a lot of repetitive `Router::new().route(...).route(...).route(...)` setup, this is where some boilerplate quietly disappears into the floorboards. + +## 5. State injection is very similar + +### Axum + +```rust +use axum::{extract::State, routing::get, Router}; +use std::sync::Arc; + +#[derive(Clone)] +struct AppState { + db: Arc, +} + +async fn users(State(state): State) -> String { + state.db.to_string() +} + +let app = Router::new().route("/users", get(users)).with_state(AppState { + db: Arc::new("db".into()), +}); +``` + +### RustAPI + +```rust +use rustapi_rs::prelude::*; +use std::sync::Arc; + +#[derive(Clone)] +struct AppState { + db: Arc, +} + +#[rustapi_rs::get("/users")] +async fn users(State(state): State) -> String { + state.db.to_string() +} + +let app = RustApi::new() + .state(AppState { + db: Arc::new("db".into()), + }) + .route("/users", get(users)); +``` + +### Migration note + +- Keep your state `Clone + Send + Sync`. +- The usual Axum pattern of storing cheap-to-clone `Arc<_>` fields still applies nicely. + +## 6. Extractor migration map + +For common endpoint code, the mapping is straightforward. + +| Axum | RustAPI | Notes | +|---|---|---| +| `State` | `State` | same pattern | +| `Path` | `Path` | same pattern | +| `Query` | `Query` | same pattern | +| `Json` | `Json` | same pattern | +| custom `FromRequestParts` | custom `FromRequestParts` | same idea for non-body extraction | +| custom `FromRequest` | custom `FromRequest` | use for body-consuming extractors | + +### Important RustAPI rule + +Body-consuming extractors such as `Json`, `Body`, `ValidatedJson`, and `Multipart` must be the **last** handler parameter. + +```rust +#[rustapi_rs::post("/users/{id}")] +async fn update_user( + State(_state): State, + Path(_id): Path, + Json(_body): Json, +) -> Result<()> { + Ok(()) +} +``` + +## 7. Middleware: tower mindset, RustAPI entry point + +If you are coming from Axum middleware, the main mental model still fits: request goes in, response comes out, layers wrap handlers. + +Apply middleware with: + +```rust,ignore +RustApi::new() + .layer(SimpleLogger) + .route("/users", get(users)); +``` + +### Migration note + +- The middleware shape is not a drop-in copy of Axum’s tower APIs. +- For simple request/response transformations, prefer RustAPI interceptors when they are sufficient; they are lighter than a full middleware layer. +- For a dedicated middleware walkthrough, see [Custom Middleware](custom_middleware.md). + +## 8. Error handling becomes more uniform + +Axum applications often build custom response tuples or custom error enums. That still works conceptually, but RustAPI leans toward `ApiError` for the common cases. + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::get("/users/{id}")] +#[rustapi_rs::errors(404 = "User not found")] +async fn get_user(Path(id): Path) -> Result> { + if id == 0 { + return Err(ApiError::not_found("User not found")); + } + + Ok(Json(User { + id, + name: "Alice".into(), + })) +} +``` + +### Migration note + +- `#[errors(...)]` documents the OpenAPI surface. +- Your handler still needs to return the actual runtime error. +- In production, RustAPI masks internal 5xx details automatically. + +## 9. OpenAPI is no longer a side quest + +In Axum, OpenAPI commonly arrives through extra libraries and extra setup. + +In RustAPI, it is part of the main story: + +- derive `Schema` for DTOs +- annotate handlers with `#[get]`, `#[post]`, etc. +- optionally add `#[tag]`, `#[summary]`, `#[description]`, `#[param]`, and `#[errors]` +- serve docs automatically through the app flow + +```rust +#[derive(Serialize, Schema)] +struct User { + id: i64, + name: String, +} + +#[rustapi_rs::get("/users/{id}")] +#[rustapi_rs::tag("Users")] +#[rustapi_rs::summary("Get user by ID")] +#[rustapi_rs::errors(404 = "User not found")] +async fn get_user(Path(id): Path) -> Result> { + Ok(Json(User { + id, + name: "Alice".into(), + })) +} +``` + +If you are migrating from Axum plus a third-party OpenAPI stack, consolidating those concerns in one framework usually makes the codebase easier to explain to Future You™. + +## 10. Testing migration: service tests -> `TestClient` + +### RustAPI test style + +```rust +use rustapi_rs::prelude::*; +use rustapi_testing::TestClient; + +#[rustapi_rs::get("/hello")] +async fn hello() -> &'static str { + "hello" +} + +#[tokio::test] +async fn test_hello() { + let app = RustApi::new().route("/hello", get(hello)); + let client = TestClient::new(app); + + let response = client.get("/hello").send().await; + + assert_eq!(response.status(), 200); +} +``` + +### Migration note + +- `TestClient` exercises the app in memory, without binding a socket. +- This is a good destination for many Axum integration tests that currently go through a service stack manually. + +## 11. Practical migration checklist + +Use this order for a low-drama migration: + +1. Replace Axum imports with `rustapi_rs::prelude::*` where possible. +2. Change route path syntax from `:id` to `{id}`. +3. Move shared dependencies into `State`. +4. Convert handlers one endpoint at a time. +5. Add `Schema` derives to DTOs that should appear in OpenAPI. +6. Replace manual route tables with route macros and `RustApi::auto()` when it reduces boilerplate. +7. Port middleware selectively instead of all at once. +8. Replace service-level tests with `TestClient` where it simplifies setup. + +## 12. A small before/after mental model + +### Axum mindset + +- compose a `Router` +- attach routes manually +- bolt on docs separately +- manage state and layers around the router + +### RustAPI mindset + +- write handler-first code +- annotate routes directly +- let `RustApi::auto()` discover them when useful +- keep docs and route metadata close to the handler + +## Related reading + +- [Macro Attribute Reference](../reference/macro_attributes.md) +- [Custom Extractors](custom_extractors.md) +- [Error Handling](error_handling.md) +- [Middleware Debugging](middleware_debugging.md) \ No newline at end of file diff --git a/docs/cookbook/src/recipes/custom_extractors.md b/docs/cookbook/src/recipes/custom_extractors.md new file mode 100644 index 00000000..178d85a1 --- /dev/null +++ b/docs/cookbook/src/recipes/custom_extractors.md @@ -0,0 +1,169 @@ +# Custom Extractors + +Custom extractors let you move repetitive request parsing out of handlers and into reusable, typed building blocks. + +Use them when a handler keeps repeating logic like: + +- reading a required header, +- validating a tenant or region identifier, +- parsing a plain-text or binary body, +- loading middleware-injected context from request extensions. + +## Problem + +Inline parsing works for one endpoint, but quickly becomes noisy when multiple handlers repeat the same header/body checks. + +## Solution + +RustAPI exposes two traits for custom extraction: + +- `FromRequestParts` for headers, path params, query params, extensions, and state +- `FromRequest` for extractors that must consume the request body + +If the extractor does **not** need the body, prefer `FromRequestParts`. + +### Example 1: Header-backed tenant extractor + +```rust +use rustapi_rs::prelude::*; + +#[derive(Debug, Clone)] +struct TenantId(String); + +impl TenantId { + fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromRequestParts for TenantId { + fn from_request_parts(req: &Request) -> Result { + let header = HeaderValue::extract(req, "x-tenant-id") + .map_err(|_| ApiError::bad_request("Missing x-tenant-id header"))?; + + let tenant = header.value().trim(); + if tenant.is_empty() { + return Err(ApiError::bad_request("x-tenant-id cannot be empty")); + } + + Ok(TenantId(tenant.to_string())) + } +} + +#[derive(Serialize, Schema)] +struct ProjectList { + tenant: String, + items: Vec, +} + +#[rustapi_rs::get("/projects")] +async fn list_projects(tenant: TenantId) -> Json { + Json(ProjectList { + tenant: tenant.as_str().to_string(), + items: vec!["alpha".into(), "beta".into()], + }) +} +``` + +### Example 2: Plain-text body extractor + +When you need to consume the request body yourself, implement `FromRequest` instead. + +```rust +use rustapi_rs::prelude::*; + +#[derive(Debug)] +struct PlainTextBody(String); + +impl PlainTextBody { + fn into_inner(self) -> String { + self.0 + } +} + +impl FromRequest for PlainTextBody { + async fn from_request(req: &mut Request) -> Result { + req.load_body().await?; + + let body = req + .take_body() + .ok_or_else(|| ApiError::internal("Body already consumed"))?; + + let text = String::from_utf8(body.to_vec()) + .map_err(|_| ApiError::bad_request("Request body must be valid UTF-8"))?; + + Ok(PlainTextBody(text)) + } +} + +#[derive(Serialize, Schema)] +struct EchoResponse { + content: String, +} + +#[rustapi_rs::post("/echo-text")] +async fn echo_text(body: PlainTextBody) -> Json { + Json(EchoResponse { + content: body.into_inner(), + }) +} +``` + +## Discussion + +### Pick the right trait + +Use `FromRequestParts` when you only need request metadata: + +- headers, +- query string, +- path parameters, +- request extensions, +- shared state. + +Use `FromRequest` only when you must consume the body. + +### Body-consuming extractors still must come last + +This rule applies to your custom body extractors too. + +```rust +async fn create_note( + State(app): State, + tenant: TenantId, + body: PlainTextBody, // body-consuming extractor goes last +) -> Result> { + # let _ = (&app, tenant, body); + # todo!() +} +``` + +### Middleware + extractors fit together nicely + +If middleware inserts typed data into request extensions, a custom extractor can read it back using the same `FromRequestParts` pattern. That keeps handlers clean and avoids repeated extension lookups. + +### Error style + +Return `ApiError` from your extractor when extraction fails. That keeps rejection behavior consistent with built-in extractors. + +## Testing + +Quick manual checks: + +```bash +curl -i http://127.0.0.1:8080/projects +curl -i -H "x-tenant-id: acme" http://127.0.0.1:8080/projects +curl -i -X POST http://127.0.0.1:8080/echo-text -H "content-type: text/plain" --data "hello" +``` + +Expected outcomes: + +- missing `x-tenant-id` returns `400`, +- valid header returns a JSON payload containing the tenant, +- plain-text echo returns the posted content as JSON. + +## Related reading + +- [Handlers & Extractors](../concepts/handlers.md) +- [Troubleshooting](../troubleshooting.md) +- [JWT Authentication](jwt_auth.md) \ No newline at end of file diff --git a/docs/cookbook/src/recipes/db_integration.md b/docs/cookbook/src/recipes/db_integration.md index 1082f34c..1abeaf55 100644 --- a/docs/cookbook/src/recipes/db_integration.md +++ b/docs/cookbook/src/recipes/db_integration.md @@ -1,21 +1,87 @@ # Database Integration -RustAPI is database-agnostic, but **SQLx** is the recommended driver due to its async-first design and compile-time query verification. +RustAPI is database-agnostic, but **SQLx** is the recommended default for most RustAPI services because it is async-first, works naturally with `State`, and supports compile-time query verification. -This recipe shows how to integrate PostgreSQL/MySQL/SQLite using a global connection pool with best practices for production. +This recipe shows how to integrate PostgreSQL/MySQL/SQLite using a shared pool, how to choose between **SQLx**, **Diesel**, and **SeaORM**, how to think about migrations, and which pooling practices are safest in production. ## Dependencies ```toml [dependencies] -rustapi-rs = { version = "0.1.335", features = ["sqlx"] } # Enable SQLx error conversion +rustapi-rs = { version = "0.1.335", features = ["extras-sqlx"] } # Canonical facade feature for SQLx error conversion sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid"] } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["full"] } dotenvy = "0.15" ``` -## 1. Setup Connection Pool +## 1. Choosing SQLx vs Diesel vs SeaORM + +RustAPI does not force a single database stack. Pick the tool that matches your team’s trade-offs. + +| Stack | Best fit | Strengths | Watch-outs | +|---|---|---|---| +| **SQLx** | Default choice for most APIs | async-first, raw SQL clarity, compile-time query checks, easy `State` integration | you write SQL yourself | +| **Diesel** | teams that want schema-driven queries and strong compile-time modeling | mature ecosystem, strong query builder, great for heavily relational domains | core query execution is synchronous, so use a pool plus `spawn_blocking` | +| **SeaORM** | teams that want a higher-level async ORM | async API, entity-oriented modeling, less handwritten SQL | more abstraction, less direct control over SQL shape, no RustAPI-specific adapter layer | + +### Practical recommendation + +- Choose **SQLx** when you want the most direct, idiomatic fit with RustAPI. +- Choose **Diesel** when your team values its schema/query-builder style enough to accept synchronous query execution boundaries. +- Choose **SeaORM** when entity-first ergonomics matter more than writing SQL manually. + +If you are unsure, start with **SQLx**. It is the least surprising option for handler-first async services. + +## 2. Migration strategy guidance + +Treat schema migrations as part of application delivery, not an afterthought. + +### Recommended strategy by stack + +- **SQLx**: keep migrations in a `migrations/` directory and apply them with `sqlx::migrate!()` at startup for local/dev workflows, or via a deployment step in CI/CD for production. +- **Diesel**: use Diesel CLI migrations as the source of truth; keep application startup focused on serving traffic rather than performing long-running schema work. +- **SeaORM**: use the SeaORM migration crate and run migrations as a separate deployment phase. + +### Production guidance + +- Prefer **forward-only** migrations in normal delivery. +- Make destructive changes in **multiple releases** when possible (add column -> dual write/read -> remove old column later). +- Run migrations **before** routing production traffic to a new version when backward compatibility is not guaranteed. +- Keep app code tolerant of short-lived mixed-schema windows during rolling deploys. +- Seed data and schema changes should be separate concerns when possible. + +For many teams, the safest pattern is: + +1. apply migrations, +2. verify readiness, +3. shift traffic, +4. clean up old schema in a later deploy. + +## 3. Connection pooling recommendations + +No matter which stack you pick, the operational rule is the same: **create the pool once at startup and share it through `State`**. + +Recommended defaults: + +- keep one long-lived pool per database/service boundary +- never open a fresh connection per request +- size pool limits from the database server’s actual connection budget +- set `acquire_timeout` so overload fails fast instead of hanging forever +- use small but non-zero `min_connections` only when warm capacity matters +- keep transaction scopes short and never hold them across unrelated awaits +- if you have API workers plus job workers, budget pool capacity for both + +As a starting point for a single service instance: + +- `max_connections`: enough for peak concurrent DB work, but well below the database hard cap +- `min_connections`: `0-5` depending on cold-start sensitivity +- `acquire_timeout`: `2-5s` +- `idle_timeout`: a few minutes, unless your environment aggressively scales to zero + +If you use a synchronous driver such as Diesel, pool the connections and execute DB work with `tokio::task::spawn_blocking` so you do not block the async runtime. + +## 4. Setup Connection Pool Create the pool once at startup and share it via `State`. Configure pool limits appropriately. @@ -61,7 +127,7 @@ async fn main() -> Result<(), Box> { } ``` -## 2. Using the Database in Handlers +## 5. Using the Database in Handlers Extract the `State` to get access to the pool. @@ -105,7 +171,7 @@ async fn create_user( } ``` -## 3. Transactions +## 6. Transactions For operations involving multiple queries, use a transaction to ensure atomicity. @@ -155,7 +221,7 @@ async fn transfer_credits( } ``` -## 4. Integration Testing with TestContainers +## 7. Integration Testing with TestContainers For testing, use `testcontainers` to spin up a real database instance. This ensures your queries are correct without mocking the database driver. @@ -208,3 +274,5 @@ RustAPI provides automatic conversion from `sqlx::Error` to `ApiError` when the - Unique Constraint Violation -> 409 Conflict - Check Constraint Violation -> 400 Bad Request - Other errors -> 500 Internal Server Error (masked in production) + +If you are using Diesel or SeaORM instead of SQLx, keep the same external error contract for handlers even though the internal database error types differ. Consistent HTTP error behavior matters more than which query builder paid the bills. diff --git a/docs/cookbook/src/recipes/deployment.md b/docs/cookbook/src/recipes/deployment.md index 9e43ddf9..2f6c5dbf 100644 --- a/docs/cookbook/src/recipes/deployment.md +++ b/docs/cookbook/src/recipes/deployment.md @@ -1,6 +1,6 @@ # Deployment -RustAPI includes built-in deployment tooling to helping you ship your applications to production with ease. The `cargo rustapi deploy` command generates configuration files and provides instructions for various platforms. +RustAPI includes built-in deployment tooling to help you ship applications, but production deployment is more than generating a config file. This guide covers both the CLI-assisted setup and the operational recommendations for health, readiness, liveness, and rollout safety. ## Supported Platforms @@ -64,3 +64,104 @@ Options: > **Note**: Shuttle.rs requires some code changes to use their runtime macro `#[shuttle_runtime::main]`. The deploy command generates the configuration but you will need to adjust your `main.rs` to use their attributes if you are deploying to their platform. +## Probe recommendations + +RustAPI has first-class built-in probe endpoints: + +- `/health` — aggregate service and dependency health +- `/ready` — readiness for load balancers and orchestrators +- `/live` — lightweight liveness probe + +You can enable them via: + +- `.health_endpoints()` +- `.with_health_check(...)` +- `.production_defaults("service-name")` + +### Recommended semantics + +- **Liveness** should answer: “Is the process alive?” +- **Readiness** should answer: “Should this instance receive traffic right now?” +- **Health** should answer: “What is the aggregate state of the service and its dependencies?” + +In practice: + +- let `/live` stay lightweight, +- let `/ready` fail when critical dependencies fail, +- let `/ready` also fail during drain/shutdown windows, +- use `/health` for richer diagnostics and dashboards. + +## Kubernetes example + +```yaml +livenessProbe: + httpGet: + path: /live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + +readinessProbe: + httpGet: + path: /ready + port: 8080 + initialDelaySeconds: 2 + periodSeconds: 5 + +startupProbe: + httpGet: + path: /live + port: 8080 + failureThreshold: 30 + periodSeconds: 2 +``` + +If you customize the paths with `HealthEndpointConfig`, update the probe configuration to match. + +## Load balancer and ingress guidance + +- Point traffic-routing health checks at `/ready`, not `/live`. +- Keep the drain window consistent with your termination grace period. +- Avoid routing public traffic to admin/debug surfaces such as `/status`, `/docs`, or `/admin/insights` unless intentionally protected. +- If auth middleware protects most routes, make sure probe routes remain reachable. + +## Minimal production bootstrap + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { + RustApi::auto() + .production_defaults("users-api") + .run("0.0.0.0:8080") + .await +} +``` + +If you need dependency-aware readiness, supply your own `HealthCheck`: + +```rust +use rustapi_rs::prelude::*; + +let health = HealthCheckBuilder::new(true) + .add_check("database", || async { + HealthStatus::healthy() + }) + .build(); + +let app = RustApi::new().with_health_check(health); +``` + +## Rollout checklist + +Before sending real traffic: + +1. `GET /live` returns `200`. +2. `GET /ready` returns `200`. +3. `GET /health` shows expected dependency state. +4. At least one business endpoint succeeds. +5. Logs and traces contain request IDs and service metadata. + +For the full operational list, see [Production Checklist](../../../PRODUCTION_CHECKLIST.md). + diff --git a/docs/cookbook/src/recipes/error_handling.md b/docs/cookbook/src/recipes/error_handling.md new file mode 100644 index 00000000..a0855411 --- /dev/null +++ b/docs/cookbook/src/recipes/error_handling.md @@ -0,0 +1,220 @@ +# Error Handling + +RustAPI ships with a structured `ApiError` type and a consistent wire format for error responses. The trick is not just returning errors, but returning the **right** error to the client while keeping internal details out of production responses. + +## Problem + +Without a clear error strategy, handlers tend to mix: + +- business errors, +- validation errors, +- infrastructure errors, and +- internal debugging details. + +That usually leads to noisy handlers and accidental leakage of sensitive information. + +## Solution + +Use `ApiError` at the HTTP boundary and convert your domain/application errors into it. + +### Basic handler pattern + +```rust +use rustapi_rs::prelude::*; + +#[derive(Serialize, Schema)] +struct UserDto { + id: u64, + email: String, +} + +#[rustapi_rs::get("/users/{id}")] +async fn get_user(Path(id): Path) -> Result> { + if id == 0 { + return Err(ApiError::bad_request("id must be greater than zero")); + } + + let user = find_user(id) + .await? + .ok_or_else(|| ApiError::not_found(format!("User {} not found", id)))?; + + Ok(Json(user)) +} + +async fn find_user(_id: u64) -> Result> { + Ok(None) +} +``` + +### Mapping application errors into `ApiError` + +```rust +use rustapi_rs::prelude::*; + +#[derive(Debug)] +enum AppError { + UserNotFound(u64), + DuplicateEmail, + Storage(std::io::Error), +} + +impl From for ApiError { + fn from(err: AppError) -> Self { + match err { + AppError::UserNotFound(id) => { + ApiError::not_found(format!("User {} not found", id)) + } + AppError::DuplicateEmail => { + ApiError::conflict("A user with that email already exists") + } + AppError::Storage(source) => { + ApiError::internal("Storage error").with_internal(source.to_string()) + } + } + } +} + +#[derive(Serialize, Schema)] +struct UserDto { + id: u64, + email: String, +} + +#[rustapi_rs::get("/users/{id}")] +async fn get_user(Path(id): Path) -> Result> { + let user = load_user(id).await?; + Ok(Json(user)) +} + +async fn load_user(id: u64) -> std::result::Result { + if id == 42 { + return Err(AppError::UserNotFound(id)); + } + + Ok(UserDto { + id, + email: "demo@example.com".into(), + }) +} +``` + +### Validation errors are already normalized + +```rust +use rustapi_rs::prelude::*; + +#[derive(Deserialize, Validate, Schema)] +struct CreateUser { + #[validate(email)] + email: String, + #[validate(length(min = 8))] + password: String, +} + +#[rustapi_rs::post("/users")] +async fn create_user(ValidatedJson(body): ValidatedJson) -> Result { + let _ = body; + Ok(StatusCode::CREATED) +} +``` + +If validation fails, RustAPI returns `422 Unprocessable Entity` automatically. + +## Error response shape + +RustAPI serializes errors as JSON like this: + +```json +{ + "error": { + "type": "not_found", + "message": "User 42 not found" + }, + "error_id": "err_a1b2c3d4e5f6..." +} +``` + +Validation errors add `fields`: + +```json +{ + "error": { + "type": "validation_error", + "message": "Request validation failed", + "fields": [ + { + "field": "email", + "code": "email", + "message": "must be a valid email" + } + ] + }, + "error_id": "err_a1b2c3d4e5f6..." +} +``` + +## Discussion + +### Use 4xx for client-facing corrections + +Good candidates for direct client messages: + +- `bad_request` +- `unauthorized` +- `forbidden` +- `not_found` +- `conflict` +- validation failures + +### Use 5xx for internal failures + +For infrastructure or unexpected failures, prefer `ApiError::internal(...)` and attach private details with `.with_internal(...)`. + +That gives operators useful logs without sending those internals to clients. + +### Production masking + +When `RUSTAPI_ENV=production`, server-side error messages are masked automatically. + +Example: + +- development 500 message: `Storage error` +- production 500 message: `An internal error occurred` + +Validation field details still remain visible. + +### Error correlation + +Every response includes an `error_id`. Use it to correlate: + +- client reports, +- server logs, +- trace/span data, +- audit or replay workflows. + +### SQLx integration + +When the SQLx feature is enabled, `sqlx::Error` converts into `ApiError` automatically. That means `?` works naturally in many handlers while still mapping common database failures to sensible HTTP responses. + +## Testing + +Manual checks: + +```bash +curl -i http://127.0.0.1:8080/users/0 +curl -i http://127.0.0.1:8080/users/42 +curl -i -X POST http://127.0.0.1:8080/users -H "content-type: application/json" --data "{\"email\":\"bad\",\"password\":\"123\"}" +``` + +What to verify: + +- `400` returns a `bad_request` error body +- `404` returns a `not_found` error body +- `422` returns `fields` entries +- every error payload contains `error_id` + +## Related reading + +- [Getting Started](../../../GETTING_STARTED.md#error-handling) +- [Database Integration](db_integration.md) +- [Troubleshooting](../troubleshooting.md) \ No newline at end of file diff --git a/docs/cookbook/src/recipes/graceful_shutdown.md b/docs/cookbook/src/recipes/graceful_shutdown.md index a98e23a6..810d9852 100644 --- a/docs/cookbook/src/recipes/graceful_shutdown.md +++ b/docs/cookbook/src/recipes/graceful_shutdown.md @@ -1,19 +1,21 @@ # Graceful Shutdown -Graceful shutdown allows your API to stop accepting new connections and finish processing active requests before terminating. This is crucial for avoiding data loss and ensuring a smooth deployment process. +Graceful shutdown lets your API stop accepting new work, drain in-flight requests, and clean up resources before the process exits. In production, the missing piece is usually **draining**: marking the instance unready so upstream load balancers stop sending traffic before shutdown completes. ## Problem -When you stop a server (e.g., via `CTRL+C` or `SIGTERM`), you want to ensure that: -1. The server stops listening on the port. -2. Ongoing requests are allowed to complete. -3. Resources (database connections, background jobs) are cleaned up properly. +When you stop a server (for example with `Ctrl+C` or `SIGTERM`), you usually want all of the following: + +1. The process stops receiving new traffic. +2. Existing requests are allowed to finish. +3. Readiness flips to unhealthy during the drain window. +4. Cleanup hooks run in a predictable order. ## Solution -RustAPI provides the `run_with_shutdown` method, which accepts a `Future`. When this future completes, the server initiates the shutdown process. +RustAPI provides `run_with_shutdown(...)`, which accepts a future. When that future resolves, the server begins graceful shutdown. If you also wire readiness to shared state, you can make the instance report `503` during the drain window before the future returns. -### Basic Example (CTRL+C) +### Basic Example ```rust use rustapi_rs::prelude::*; @@ -21,17 +23,14 @@ use tokio::signal; #[tokio::main] async fn main() -> Result<()> { - // 1. Define your application let app = RustApi::new().route("/", get(hello)); - // 2. Define the shutdown signal let shutdown_signal = async { signal::ctrl_c() .await .expect("failed to install CTRL+C handler"); }; - // 3. Run with shutdown println!("Server running... Press CTRL+C to stop."); app.run_with_shutdown("127.0.0.1:3000", shutdown_signal).await?; @@ -40,30 +39,62 @@ async fn main() -> Result<()> { } async fn hello() -> &'static str { - // Simulate some work tokio::time::sleep(std::time::Duration::from_secs(2)).await; "Hello, World!" } ``` -### Production Example (Unix Signals) +### Production Example with Draining + +In orchestrated environments you usually want to: -In a production environment (like Kubernetes or Docker), you need to handle `SIGTERM` as well as `SIGINT`. +1. listen for `SIGTERM` as well as `Ctrl+C`, +2. mark the instance as draining, +3. wait for a short drain window, and only then +4. let `run_with_shutdown(...)` finish the shutdown. ```rust use rustapi_rs::prelude::*; -use tokio::signal; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; +use tokio::{ + signal, + time::{sleep, Duration}, +}; #[tokio::main] -async fn main() -> Result<()> { - let app = RustApi::new().route("/", get(hello)); - - app.run_with_shutdown("0.0.0.0:3000", shutdown_signal()).await?; +async fn main() -> std::result::Result<(), Box> { + let draining = Arc::new(AtomicBool::new(false)); + let readiness_flag = draining.clone(); + + let health = HealthCheckBuilder::new(true) + .add_check("draining", move || { + let readiness_flag = readiness_flag.clone(); + async move { + if readiness_flag.load(Ordering::SeqCst) { + HealthStatus::unhealthy("draining") + } else { + HealthStatus::healthy() + } + } + }) + .build(); + + let app = RustApi::new() + .with_health_check(health) + .on_shutdown(|| async { + tracing::info!("shutdown cleanup finished"); + }) + .route("/", get(hello)); + + app.run_with_shutdown("0.0.0.0:3000", shutdown_signal(draining)).await?; Ok(()) } -async fn shutdown_signal() { +async fn shutdown_signal(draining: Arc) { let ctrl_c = async { signal::ctrl_c() .await @@ -85,11 +116,35 @@ async fn shutdown_signal() { _ = ctrl_c => println!("Received Ctrl+C"), _ = terminate => println!("Received SIGTERM"), } + + draining.store(true, Ordering::SeqCst); + sleep(Duration::from_secs(15)).await; +} + +async fn hello() -> &'static str { + sleep(Duration::from_secs(2)).await; + "Hello, World!" } ``` ## Discussion -- **Active Requests**: RustAPI (via Hyper) will wait for active requests to complete. -- **Timeout**: You might want to wrap the server execution in a timeout if you want to force shutdown after a certain period (though Hyper usually handles connection draining well). -- **Background Tasks**: If you have spawned background tasks using `tokio::spawn`, they are detached and will be aborted when the runtime shuts down. For critical background work, consider using a dedicated job queue (like `rustapi-jobs`) or a `CancellationToken` to coordinate shutdown. +- **Active requests**: RustAPI waits for in-flight requests to complete as shutdown proceeds. +- **Drain window**: The sleep inside `shutdown_signal(...)` gives your ingress or load balancer time to observe readiness failure and stop sending new traffic. +- **Readiness semantics**: By wiring readiness to shared state, `/ready` can return `503 Service Unavailable` while `/live` still reports that the process is alive. +- **Cleanup hooks**: `on_shutdown(...)` hooks are executed after the shutdown signal future resolves, making them a good place for final flush/cleanup work. +- **Detached tasks**: `tokio::spawn` tasks are still detached. For critical work, coordinate them explicitly or move the work into a durable queue such as `rustapi-jobs`. +- **Forceful shutdown**: If your platform requires a hard upper bound, combine this approach with a platform-level termination grace period and an application-level timeout policy. + +## Recommended production pattern + +For most deployments: + +1. Receive `SIGTERM`. +2. Mark the instance as draining. +3. Let readiness fail. +4. Wait 10–30 seconds, depending on your proxy and traffic pattern. +5. Allow graceful shutdown to complete. +6. Run shutdown hooks. + +Pair this with the cookbook [Deployment](deployment.md) recipe and the docs [Production Checklist](../../../PRODUCTION_CHECKLIST.md). diff --git a/docs/cookbook/src/recipes/middleware_debugging.md b/docs/cookbook/src/recipes/middleware_debugging.md new file mode 100644 index 00000000..d6b39066 --- /dev/null +++ b/docs/cookbook/src/recipes/middleware_debugging.md @@ -0,0 +1,179 @@ +# Middleware Debugging + +Middleware bugs are rarely glamorous. They usually look like: + +- a handler never running, +- a missing `x-request-id`, +- tracing spans without correlation, +- an extractor failing because middleware never inserted the expected extension, +- a response being transformed by the “wrong” layer. + +This guide focuses on debugging the middleware you already have in your stack. + +## Problem + +Middleware wraps handlers from the outside in, so when something goes wrong the visible symptom is often far away from the actual cause. + +## Solution + +Start with a minimal, observable stack and verify one layer at a time. + +### Understand execution order first + +RustAPI executes layers in the order they are added: + +- the **first** `.layer(...)` sees the request first, +- the **last** `.layer(...)` sees the response first on the way back out. + +```rust +use rustapi_rs::prelude::*; + +#[rustapi_rs::get("/")] +async fn index() -> &'static str { + "ok" +} + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { + RustApi::auto() + .layer(RequestIdLayer::new()) + .layer( + TracingLayer::new() + .with_field("service", "debug-demo") + .with_field("environment", "development"), + ) + .run("127.0.0.1:8080") + .await +} +``` + +For the request path, the order is: + +1. `RequestIdLayer` +2. `TracingLayer` +3. handler + +For the response path, it unwinds in reverse. + +## A practical debugging workflow + +### 1. Verify request correlation + +Start by confirming `RequestIdLayer` is active. + +```bash +curl -i http://127.0.0.1:8080/ +``` + +If the response does not include `x-request-id`, either: + +- `RequestIdLayer` is missing, +- the request never reached that layer, or +- another layer or proxy is mutating headers unexpectedly. + +### 2. Verify tracing sees the request ID + +`TracingLayer` reads the request ID from request extensions. If it runs without `RequestIdLayer`, the span records `request_id = "unknown"`. + +That makes the pairing easy to diagnose: + +- `x-request-id` present + trace has request ID → good +- no `x-request-id` + trace shows `unknown` → missing request ID layer + +### 3. Reduce the stack + +If a handler is not reached, strip the app down to the smallest reproducer: + +```rust +RustApi::new() + .layer(RequestIdLayer::new()) + .route("/", get(index)); +``` + +Then add layers back one by one until the failure returns. It is boring, but boring debugging is usually the fastest debugging. + +### 4. Watch for short-circuiting + +Some middleware returns a response early and never calls downstream layers or the handler. Common examples include: + +- auth failures, +- timeout layers, +- CORS preflight handling, +- rate limits, +- custom guards. + +If a request fails before the handler runs, suspect an outer layer first. + +## Common failure modes + +### `RequestId` extractor fails inside a handler + +Symptom: + +- handler returns an internal error saying the request ID was not found. + +Likely cause: + +- `RequestIdLayer` was not added. + +### `Extension` extractor fails + +Symptom: + +- handler says an extension was not found. + +Likely cause: + +- the middleware that should insert that extension never ran, +- it short-circuited before insertion, +- or the inserted type does not match the extracted type exactly. + +### Logs exist but are hard to correlate + +Add `RequestIdLayer` and keep `TracingLayer` close to the edge of the stack so every request has a stable identifier early. + +### Response looks modified “too late” + +Remember response processing unwinds in reverse. The last layer added has the first chance to modify the outgoing response. + +## Built-in tools that help + +### Status page + +The built-in status page helps answer whether traffic is reaching the service and which endpoints are hot. + +```rust +RustApi::auto().status_page(); +``` + +### Observability stack + +If the issue spans multiple services, combine: + +- `RequestIdLayer` +- `TracingLayer` +- `OtelLayer` +- `StructuredLoggingLayer` + +See the [Observability](observability.md) recipe for the recommended baseline. + +### TestClient + +For reproducible debugging, build a small app and exercise it with the in-memory test client. That way you can inspect middleware behavior without involving a real network hop. + +## Debug checklist + +- [ ] Does the response include `x-request-id`? +- [ ] Does tracing log the same request ID instead of `unknown`? +- [ ] Is the handler actually being reached? +- [ ] Could an outer middleware be short-circuiting? +- [ ] Is layer order what you think it is? +- [ ] If using `Extension`, does the inserted type exactly match the extracted type? +- [ ] Have you reproduced the issue with a minimal stack? + +## Related reading + +- [Custom Middleware](custom_middleware.md) +- [Observability](observability.md) +- [Graceful Shutdown](graceful_shutdown.md) +- [Troubleshooting](../troubleshooting.md) \ No newline at end of file diff --git a/docs/cookbook/src/recipes/oauth2_client.md b/docs/cookbook/src/recipes/oauth2_client.md index 59710868..393e85f7 100644 --- a/docs/cookbook/src/recipes/oauth2_client.md +++ b/docs/cookbook/src/recipes/oauth2_client.md @@ -1,16 +1,16 @@ # OAuth2 Client Integration -Integrating with third-party identity providers (like Google, GitHub) is a common requirement for modern applications. RustAPI provides a streamlined OAuth2 client in `rustapi-extras`. +Integrating with third-party identity providers (like Google, GitHub) is a common requirement for modern applications. RustAPI exposes the OAuth2 client through the public `rustapi-rs` facade. This recipe demonstrates how to set up an OAuth2 flow. ## Prerequisites -Add `rustapi-extras` with the `oauth2-client` feature to your `Cargo.toml`. +Enable the canonical facade feature in `rustapi-rs`. ```toml [dependencies] -rustapi-extras = { version = "0.1.335", features = ["oauth2-client"] } +rustapi-rs = { version = "0.1.389", features = ["extras-oauth2-client"] } ``` ## Basic Configuration @@ -18,7 +18,7 @@ rustapi-extras = { version = "0.1.335", features = ["oauth2-client"] } You can use presets for popular providers or configure a custom one. ```rust -use rustapi_extras::oauth2::{OAuth2Config, Provider}; +use rustapi_rs::extras::oauth2::OAuth2Config; // Using a preset (Google) let config = OAuth2Config::google( @@ -28,12 +28,12 @@ let config = OAuth2Config::google( ); // Or custom provider -let custom_config = OAuth2Config::new( - "client-id", - "client-secret", +let custom_config = OAuth2Config::custom( "https://auth.example.com/authorize", "https://auth.example.com/token", - "https://your-app.com/callback" + "client-id", + "client-secret", + "https://your-app.com/callback", ); ``` @@ -46,16 +46,17 @@ let custom_config = OAuth2Config::new( ```rust use rustapi_rs::prelude::*; -use rustapi_extras::oauth2::{OAuth2Client, OAuth2Config}; +use rustapi_rs::extras::oauth2::OAuth2Client; +use rustapi_rs::extras::session::Session; -async fn login(client: State) -> impl IntoResponse { +async fn login(State(client): State, session: Session) -> Redirect { // Generate URL with CSRF protection and PKCE let auth_request = client.authorization_url(); - // Store CSRF token and PKCE verifier in session (or cookie) - // In a real app, use secure, http-only cookies - // session.insert("csrf_token", auth_request.csrf_state.secret()); - // session.insert("pkce_verifier", auth_request.pkce_verifier.secret()); + session.insert("oauth_state", auth_request.csrf_state.as_str()).await.expect("state should serialize"); + if let Some(pkce) = auth_request.pkce_verifier.as_ref() { + session.insert("oauth_pkce_verifier", pkce.verifier()).await.expect("pkce should serialize"); + } // Redirect user Redirect::to(auth_request.url().as_str()) @@ -66,7 +67,8 @@ async fn login(client: State) -> impl IntoResponse { ```rust use rustapi_rs::prelude::*; -use rustapi_extras::oauth2::{OAuth2Client, OAuth2Config}; +use rustapi_rs::extras::oauth2::{CsrfState, OAuth2Client, PkceVerifier}; +use rustapi_rs::extras::session::Session; #[derive(Deserialize)] struct AuthCallback { @@ -75,16 +77,25 @@ struct AuthCallback { } async fn callback( + State(client): State, + session: Session, Query(params): Query, - client: State, - // session: Session, // Assuming session management ) -> impl IntoResponse { - // 1. Verify CSRF token from session matches params.state + let expected_state = session.get::("oauth_state").await.unwrap().unwrap(); + client + .validate_state(&CsrfState::new(expected_state), ¶ms.state) + .expect("invalid oauth state"); + + let pkce_verifier = session + .get::("oauth_pkce_verifier") + .await + .unwrap() + .map(PkceVerifier::new); // 2. Exchange code for token - // let pkce_verifier = session.get("pkce_verifier").unwrap(); + let token_response = client.exchange_code(¶ms.code, pkce_verifier.as_ref()).await; - match client.exchange_code(¶ms.code, /* pkce_verifier */).await { + match token_response { Ok(token_response) => { // Success! You have an access token. // Use it to fetch user info or store it. @@ -123,5 +134,8 @@ async fn get_user_info(token: &str) -> Result 1. **State Parameter**: Always use the `state` parameter to prevent CSRF attacks. RustAPI's `authorization_url()` generates one for you. 2. **PKCE**: Proof Key for Code Exchange (PKCE) is recommended for all OAuth2 flows, especially for public clients. RustAPI handles PKCE generation. -3. **Secure Storage**: Store tokens securely (e.g., encrypted cookies, secure session storage). Never expose access tokens in URLs or logs. +3. **Session Storage**: Store the CSRF state and PKCE verifier in a secure server-side session. Pair `extras-oauth2-client` with `extras-session` for the cleanest flow. +4. **Secure Storage**: Store tokens securely (e.g., encrypted cookies, secure session storage). Never expose access tokens in URLs or logs. 4. **HTTPS**: OAuth2 requires HTTPS callbacks in production. + +For a production-focused checklist, redirect strategy, and session integration guidance, continue with [OIDC & OAuth2 in Production](oidc_oauth2_production.md). diff --git a/docs/cookbook/src/recipes/observability.md b/docs/cookbook/src/recipes/observability.md new file mode 100644 index 00000000..b506ea20 --- /dev/null +++ b/docs/cookbook/src/recipes/observability.md @@ -0,0 +1,177 @@ +# Observability + +Production services need more than “logs exist somewhere”. A healthy RustAPI observability setup should let you answer three questions quickly: + +1. **What failed?** +2. **Which request or trace did it belong to?** +3. **Is this isolated or systemic?** + +This recipe shows a pragmatic observability stack using: + +- `production_defaults(...)` for request IDs and request tracing, +- `OtelLayer` for distributed traces, +- `StructuredLoggingLayer` for machine-readable logs, and +- `InsightLayer` for in-process traffic analytics. + +## Prerequisites + +Enable the relevant features: + +```toml +[dependencies] +rustapi-rs = { version = "0.1.335", features = [ + "core", + "extras-otel", + "extras-structured-logging", + "extras-insight" +] } +``` + +## Basic Usage + +```rust +use rustapi_rs::prelude::*; +use rustapi_rs::extras::{ + insight::{InsightConfig, InsightLayer}, + otel::{OtelConfig, OtelLayer}, + structured_logging::{LogOutputFormat, StructuredLoggingConfig, StructuredLoggingLayer}, +}; + +#[rustapi_rs::get("/")] +async fn hello() -> &'static str { + "hello" +} + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { + let environment = std::env::var("RUSTAPI_ENV") + .unwrap_or_else(|_| "development".to_string()); + + RustApi::auto() + .production_defaults("billing-api") + .layer(OtelLayer::new( + OtelConfig::builder() + .service_name("billing-api") + .service_version(env!("CARGO_PKG_VERSION")) + .deployment_environment(environment.clone()) + .endpoint("http://otel-collector:4317") + .exclude_paths(vec![ + "/health".to_string(), + "/ready".to_string(), + "/live".to_string(), + ]) + .build(), + )) + .layer(StructuredLoggingLayer::new( + StructuredLoggingConfig::builder() + .format(LogOutputFormat::Json) + .service_name("billing-api") + .service_version(env!("CARGO_PKG_VERSION")) + .environment(environment) + .correlation_id_header("x-request-id") + .exclude_paths(vec![ + "/health".to_string(), + "/ready".to_string(), + "/live".to_string(), + ]) + .build(), + )) + .layer(InsightLayer::with_config( + InsightConfig::new() + .sample_rate(0.20) + .skip_paths(["/health", "/ready", "/live"]) + .header_whitelist(["content-type", "user-agent", "x-request-id"]) + .response_header_whitelist(["content-type", "x-request-id"]) + .dashboard_path(Some("/admin/insights")) + .stats_path(Some("/admin/insights/stats")), + )) + .run("0.0.0.0:8080") + .await +} +``` + +## The recommended “golden config” + +For most APIs, the following defaults work well: + +### 1. Request correlation everywhere + +Use the production preset so every request already carries a request ID and tracing span. This gives you a stable correlation key before you add any external observability backend. + +### 2. JSON logs in production + +Prefer `StructuredLoggingLayer` with: + +- `LogOutputFormat::Json` +- `service_name` +- `service_version` +- `environment` +- `correlation_id_header("x-request-id")` + +That makes it easy to join app logs with request IDs emitted by the built-in preset. + +### 3. OTel for distributed traces + +Use `OtelLayer` when your service participates in a larger system. Set: + +- service name, +- service version, +- deployment environment, +- collector endpoint, +- excluded probe paths. + +### 4. Insight for local traffic intelligence + +`InsightLayer` is useful for: + +- endpoint hot spots, +- latency outliers, +- lightweight internal dashboards, +- short-term debugging without a full external analytics platform. + +Use sampling in production and keep the dashboard on a private/admin route. + +## What each layer is responsible for + +| Layer | Purpose | +|-------|---------| +| `TracingLayer` (via production preset) | Request-scoped tracing spans with service metadata | +| `OtelLayer` | Distributed trace export and propagation | +| `StructuredLoggingLayer` | Machine-readable application/request logs | +| `InsightLayer` | In-process request analytics and dashboards | + +These tools complement each other rather than replace each other. + +## Noise control + +Probe routes can dominate dashboards and logs in busy clusters. A good default is to exclude `/health`, `/ready`, and `/live` from: + +- OTel export, +- structured logs, and +- insight capture. + +If you need probe telemetry for a specific incident, re-enable it deliberately rather than keeping it on all the time. + +## Sensitive data guidance + +- Leave request/response body capture off unless debugging requires it. +- Whitelist only the headers you actually need. +- Keep `authorization`, `cookie`, and API-key style headers redacted. +- Treat admin insight endpoints as internal surfaces. + +## Operational tips + +1. Include `env!("CARGO_PKG_VERSION")` in logs and traces. +2. Make dashboards searchable by `x-request-id`, `trace_id`, and `error_id`. +3. Keep observability config close to your app bootstrap, not hidden in scattered helpers. +4. Validate the full path with one real request before rollout: + - response has `X-Request-ID`, + - logs include the correlation ID, + - traces reach the collector, + - insight dashboard records traffic if enabled. + +## Related guides + +- [Recommended Production Baseline](../../../PRODUCTION_BASELINE.md) +- [Production Checklist](../../../PRODUCTION_CHECKLIST.md) +- [Graceful Shutdown](graceful_shutdown.md) \ No newline at end of file diff --git a/docs/cookbook/src/recipes/oidc_oauth2_production.md b/docs/cookbook/src/recipes/oidc_oauth2_production.md new file mode 100644 index 00000000..98204e7a --- /dev/null +++ b/docs/cookbook/src/recipes/oidc_oauth2_production.md @@ -0,0 +1,178 @@ +# OIDC / OAuth2 in Production + +This guide turns the basic OAuth2 client into a production-ready login flow. + +The short version: + +- use `OAuth2Client` to generate the authorization URL, +- store CSRF state and PKCE verifier in a server-side session, +- verify `state` on callback, +- exchange the code for tokens, +- rotate the application session before marking the user as authenticated. + +## Prerequisites + +Enable both the OAuth2 client and session features on the public facade. + +```toml +[dependencies] +rustapi-rs = { version = "0.1.389", features = ["extras-oauth2-client", "extras-session"] } +``` + +## Configure the provider + +Use one of the provider presets when possible. + +```rust +use rustapi_rs::extras::oauth2::{OAuth2Client, OAuth2Config}; + +let config = OAuth2Config::google( + std::env::var("OAUTH_CLIENT_ID")?, + std::env::var("OAUTH_CLIENT_SECRET")?, + std::env::var("OAUTH_REDIRECT_URI")?, +) +.scope("openid") +.scope("email") +.scope("profile"); + +let client = OAuth2Client::new(config); +``` + +For non-preset providers, use `OAuth2Config::custom(...)`. + +```rust +use rustapi_rs::extras::oauth2::OAuth2Config; + +let config = OAuth2Config::custom( + "https://id.example.com/oauth/authorize", + "https://id.example.com/oauth/token", + std::env::var("OAUTH_CLIENT_ID")?, + std::env::var("OAUTH_CLIENT_SECRET")?, + std::env::var("OAUTH_REDIRECT_URI")?, +); +``` + +## Authorization redirect + +The authorization handler should generate the provider URL and persist the CSRF + PKCE data in the current session. + +```rust +use rustapi_rs::prelude::*; +use rustapi_rs::extras::oauth2::OAuth2Client; +use rustapi_rs::extras::session::Session; + +async fn oauth_login(State(client): State, session: Session) -> Redirect { + let auth_request = client.authorization_url(); + + session + .insert("oauth_state", auth_request.csrf_state.as_str()) + .await + .expect("state should serialize"); + + if let Some(pkce) = auth_request.pkce_verifier.as_ref() { + session + .insert("oauth_pkce_verifier", pkce.verifier()) + .await + .expect("pkce verifier should serialize"); + } + + Redirect::to(auth_request.url()) +} +``` + +## Callback handling + +The callback handler validates the CSRF state, exchanges the code, and upgrades the application session. + +```rust +use rustapi_rs::prelude::*; +use rustapi_rs::extras::oauth2::{CsrfState, OAuth2Client, PkceVerifier}; +use rustapi_rs::extras::session::Session; + +#[derive(Debug, Deserialize, Schema)] +struct OAuthCallback { + code: String, + state: String, +} + +async fn oauth_callback( + State(client): State, + session: Session, + Query(callback): Query, +) -> Result { + let expected_state = session + .get::("oauth_state") + .await? + .ok_or_else(|| ApiError::unauthorized("Missing OAuth state"))?; + + client + .validate_state(&CsrfState::new(expected_state), &callback.state) + .map_err(|error| ApiError::unauthorized(error.to_string()))?; + + let pkce_verifier = session + .get::("oauth_pkce_verifier") + .await? + .map(PkceVerifier::new); + + let tokens = client + .exchange_code(&callback.code, pkce_verifier.as_ref()) + .await + .map_err(|error| ApiError::unauthorized(error.to_string()))?; + + session.cycle_id().await; + session.insert("user_id", "provider-subject-here").await?; + session.insert("refresh_token", tokens.refresh_token()).await?; + session.remove("oauth_state").await; + session.remove("oauth_pkce_verifier").await; + + Ok(Redirect::to("/dashboard")) +} +``` + +## Recommended production shape + +### Session strategy + +- Keep provider state (`oauth_state`, PKCE verifier, post-login redirect path) in the session, not in query strings. +- Rotate the app session ID after a successful login with `session.cycle_id().await`. +- Prefer `RedisSessionStore` when multiple instances share login traffic. +- Clear bootstrap OAuth keys from the session after the callback succeeds or fails. + +### Token handling + +- Do not log raw `access_token`, `refresh_token`, or `id_token` values. +- If you only need app authentication, store the provider subject and essential claims instead of the raw access token. +- If you must keep refresh tokens, treat them like secrets: server-side only, never in frontend-readable cookies. +- Call `refresh_token(...)` only from trusted backend paths and overwrite old refresh tokens if the provider rotates them. + +### Provider and redirect hygiene + +- Use exact HTTPS redirect URIs in production. +- Request the minimum scopes you need. +- Pin timeouts explicitly via `OAuth2Config::timeout(...)` if your provider is slow. +- Prefer issuer/provider presets unless you fully control the custom identity server. + +### Identity verification + +- OpenID Connect is more than “OAuth + vibes”. Validate the `id_token` with the provider’s JWKs before trusting identity claims. +- Use the provider `userinfo` endpoint only after you decide which claims are authoritative. +- Normalize external identities into your own application user model before starting long-lived sessions. + +## Local development + +For local work, keep session cookies developer-friendly while still matching production flow structure. + +```rust +use rustapi_rs::extras::session::SessionConfig; + +let session_config = SessionConfig::new() + .cookie_name("rustapi_auth") + .secure(false); +``` + +That keeps the cookie usable over `http://127.0.0.1:3000` while preserving the same handler code. + +## See also + +- [OAuth2 Client](oauth2_client.md) +- [Session-Based Authentication](session_auth.md) diff --git a/docs/cookbook/src/recipes/session_auth.md b/docs/cookbook/src/recipes/session_auth.md new file mode 100644 index 00000000..0d812bf2 --- /dev/null +++ b/docs/cookbook/src/recipes/session_auth.md @@ -0,0 +1,170 @@ +# Session-Based Authentication + +Cookie-backed session auth is the shortest path from “I need login/logout” to a production-shaped RustAPI service. + +This recipe shows how to: + +- load a session from a cookie before your handler runs, +- read and mutate session data through the `Session` extractor, +- rotate the session ID on login / refresh, +- swap the store backend from memory to Redis without changing handler code. + +## Prerequisites + +Enable the session feature on the public facade. + +```toml +[dependencies] +rustapi-rs = { version = "0.1.389", features = ["extras-session"] } +``` + +If you want Redis-backed sessions, add the Redis backend feature too: + +```toml +[dependencies] +rustapi-rs = { version = "0.1.389", features = ["extras-session", "extras-session-redis"] } +``` + +## Solution + +`rustapi-rs` now exposes the full session flow through the facade. + +```rust +use rustapi_rs::prelude::*; +use rustapi_rs::extras::session::{MemorySessionStore, Session, SessionConfig, SessionLayer}; +use std::time::Duration; + +#[derive(Debug, Deserialize, Schema)] +struct LoginRequest { + user_id: String, +} + +#[derive(Debug, Serialize, Schema)] +struct SessionView { + authenticated: bool, + user_id: Option, + refreshed: bool, + session_id: Option, +} + +async fn session_view(session: &Session) -> SessionView { + let user_id = session.get::("user_id").await.ok().flatten(); + let refreshed = session + .get::("refreshed") + .await + .ok() + .flatten() + .unwrap_or(false); + + SessionView { + authenticated: user_id.is_some(), + user_id, + refreshed, + session_id: session.id().await, + } +} + +async fn login(session: Session, Json(payload): Json) -> Json { + session.cycle_id().await; + session.insert("user_id", &payload.user_id).await.expect("session insert"); + session.insert("refreshed", false).await.expect("session insert"); + Json(session_view(&session).await) +} + +async fn me(session: Session) -> Json { + Json(session_view(&session).await) +} + +async fn refresh(session: Session) -> Json { + if session.contains("user_id").await { + session.cycle_id().await; + session.insert("refreshed", true).await.expect("session insert"); + } + + Json(session_view(&session).await) +} + +async fn logout(session: Session) -> NoContent { + session.destroy().await; + NoContent +} + +let app = RustApi::new() + .layer(SessionLayer::new( + MemorySessionStore::new(), + SessionConfig::new() + .cookie_name("rustapi_auth") + .secure(false) + .ttl(Duration::from_secs(60 * 30)), + )) + .route("/auth/login", post(login)) + .route("/auth/me", get(me)) + .route("/auth/refresh", post(refresh)) + .route("/auth/logout", post(logout)); +``` + +A complete runnable version lives in `crates/rustapi-rs/examples/auth_api.rs`. + +## How the flow works + +1. `SessionLayer` parses the incoming session cookie. +2. The configured store loads the matching `SessionRecord`. +3. The `Session` extractor gives handlers typed access to the record. +4. Handler mutations are persisted after the response is produced. +5. If the session was changed, the middleware emits a new `Set-Cookie` header. +6. `session.destroy().await` deletes the record and clears the cookie. + +That means your handlers stay focused on business logic while the middleware handles persistence and cookie management. + +## Built-in store options + +### In-memory store + +Use `MemorySessionStore` for tests, demos, and single-node deployments. + +```rust +use rustapi_rs::extras::session::{MemorySessionStore, SessionConfig, SessionLayer}; + +let layer = SessionLayer::new( + MemorySessionStore::new(), + SessionConfig::new(), +); +``` + +### Redis-backed store + +Use `RedisSessionStore` when sessions must survive restarts or be shared across instances. + +```rust +use rustapi_rs::extras::session::{RedisSessionStore, SessionConfig, SessionLayer}; + +let store = RedisSessionStore::from_url(&std::env::var("REDIS_URL")?)? + .key_prefix("rustapi:session:"); + +let layer = SessionLayer::new(store, SessionConfig::new()); +``` + +The handler API is identical. Only the store changes. + +## Configuration notes + +- Keep `cookie_http_only = true` for session cookies. +- Use `secure(true)` in production so cookies are HTTPS-only. +- Use `same_site(SameSite::Lax)` or stricter unless your cross-site flow needs otherwise. +- Rotate the session ID on login and privilege changes with `session.cycle_id().await` to reduce session fixation risk. +- Prefer short TTLs plus rolling expiry for end-user sessions. +- Store only what you need in the session payload. Opaque IDs age better than giant identity blobs. + +## Verification + +Run the built-in session tests first: + +```sh +cargo test -p rustapi-extras --features session +``` + +Then try the runnable example: + +```sh +cargo run -p rustapi-rs --example auth_api --features extras-session +``` diff --git a/docs/cookbook/src/reference/README.md b/docs/cookbook/src/reference/README.md index f01e65da..3d80e652 100644 --- a/docs/cookbook/src/reference/README.md +++ b/docs/cookbook/src/reference/README.md @@ -1,3 +1,5 @@ # Reference -Coming soon: Links to `cargo doc` and API specifications. +Focused references for APIs, metadata, and syntax details that are easier to scan than long-form guides. + +- [Macro Attribute Reference](macro_attributes.md) diff --git a/docs/cookbook/src/reference/macro_attributes.md b/docs/cookbook/src/reference/macro_attributes.md new file mode 100644 index 00000000..efd53fa4 --- /dev/null +++ b/docs/cookbook/src/reference/macro_attributes.md @@ -0,0 +1,270 @@ +# Macro Attribute Reference + +RustAPI’s attribute macros do two jobs at once: + +1. they register routes and schemas at compile time, and +2. they enrich the generated OpenAPI operation metadata. + +This reference focuses on the route metadata attributes most users need first: + +- `#[tag(...)]` +- `#[summary(...)]` +- `#[description(...)]` +- `#[param(...)]` +- `#[errors(...)]` + +> **Golden rule:** In user code, use the facade macros from `rustapi-rs`, e.g. `#[rustapi_rs::get(...)]`, not internal crates. + +## Typical usage + +```rust +use rustapi_rs::prelude::*; + +#[derive(Serialize, Schema)] +struct User { + id: String, + name: String, +} + +#[rustapi_rs::get("/users/{id}")] +#[rustapi_rs::tag("Users")] +#[rustapi_rs::summary("Get user by ID")] +#[rustapi_rs::description("Returns a single user by its unique identifier.")] +#[rustapi_rs::param(id, schema = "uuid")] +#[rustapi_rs::errors(404 = "User not found", 403 = "Forbidden")] +async fn get_user(Path(_id): Path) -> Result> { + Ok(Json(User { + id: "550e8400-e29b-41d4-a716-446655440000".into(), + name: "Alice".into(), + })) +} +``` + +## `#[rustapi_rs::tag("...")]` + +Groups the operation under one or more OpenAPI tags. + +### Syntax + +```rust +#[rustapi_rs::tag("Users")] +``` + +### Effect + +- Appends the tag value to the operation’s `tags` list. +- Useful for Swagger grouping and cookbook-style API organization. + +### Example + +```rust +#[rustapi_rs::get("/items")] +#[rustapi_rs::tag("Items")] +async fn list_items() -> &'static str { + "ok" +} +``` + +## `#[rustapi_rs::summary("...")]` + +Sets the short OpenAPI summary for the operation. + +### Syntax + +```rust +#[rustapi_rs::summary("List all items")] +``` + +### Effect + +- Fills the operation summary shown in Swagger and generated specs. +- Best used as a short, action-oriented sentence. + +### Example + +```rust +#[rustapi_rs::get("/items")] +#[rustapi_rs::summary("List all items")] +async fn list_items() -> &'static str { + "ok" +} +``` + +## `#[rustapi_rs::description("...")]` + +Sets the longer description for the operation. + +### Syntax + +```rust +#[rustapi_rs::description("Returns all active items. Supports pagination.")] +``` + +### Effect + +- Fills the operation description field. +- Good for behavior notes, pagination semantics, or auth requirements. + +### Example + +```rust +#[rustapi_rs::get("/items")] +#[rustapi_rs::description("Returns active items only. Archived items are excluded.")] +async fn list_items() -> &'static str { + "ok" +} +``` + +## `#[rustapi_rs::param(...)]` + +Overrides the OpenAPI schema type for a **path parameter**. + +This is useful when the auto-inferred type is not the schema shape you want to expose in docs. + +### Supported schema types + +- `"uuid"` +- `"integer"` or `"int"` +- `"string"` +- `"boolean"` or `"bool"` +- `"number"` + +### Supported forms + +Form 1: + +```rust +#[rustapi_rs::param(id, schema = "uuid")] +``` + +Form 2: + +```rust +#[rustapi_rs::param(id = "uuid")] +``` + +### Effect + +- Adds a custom path parameter schema override to the generated route metadata. +- Particularly useful for IDs that are represented as strings but should be documented with UUID semantics. + +### Example + +```rust +#[rustapi_rs::get("/orders/{order_id}")] +#[rustapi_rs::param(order_id, schema = "uuid")] +async fn get_order(Path(_order_id): Path) -> &'static str { + "ok" +} +``` + +### Notes + +- This attribute is intended for **path parameters**. +- RustAPI already auto-detects path params from handler signatures; `#[param(...)]` is an override, not a requirement. + +## `#[rustapi_rs::errors(...)]` + +Declares additional typed error responses for OpenAPI. + +### Syntax + +```rust +#[rustapi_rs::errors(404 = "User not found", 403 = "Forbidden")] +``` + +### Effect + +- Adds those responses directly to the operation’s OpenAPI response map. +- Each declared response uses the standard `ErrorSchema` under `application/json`. + +### Example + +```rust +#[rustapi_rs::delete("/users/{id}")] +#[rustapi_rs::errors(404 = "User not found")] +async fn delete_user(Path(_id): Path) -> Result<()> { + Ok(()) +} +``` + +### Multiple status codes + +```rust +#[rustapi_rs::post("/users")] +#[rustapi_rs::errors( + 400 = "Invalid input", + 409 = "Email already exists", + 422 = "Validation failed" +)] +async fn create_user(Json(_body): Json) -> Result> { + # todo!() +} +``` + +## Interaction with route macros + +These metadata attributes are consumed by the HTTP method macros such as: + +- `#[rustapi_rs::get(...)]` +- `#[rustapi_rs::post(...)]` +- `#[rustapi_rs::put(...)]` +- `#[rustapi_rs::patch(...)]` +- `#[rustapi_rs::delete(...)]` + +The route macro gathers metadata from the other attributes and turns them into builder calls such as: + +- `.tag(...)` +- `.summary(...)` +- `.description(...)` +- `.param(...)` +- `.error_response(...)` + +## Recommended ordering + +Keep the route macro first, then place metadata attributes below it: + +```rust +#[rustapi_rs::get("/users/{id}")] +#[rustapi_rs::tag("Users")] +#[rustapi_rs::summary("Get user")] +#[rustapi_rs::param(id, schema = "uuid")] +#[rustapi_rs::errors(404 = "User not found")] +async fn get_user(Path(_id): Path) -> Result<&'static str> { + Ok("ok") +} +``` + +That matches the style already used across the repository and keeps metadata easy to scan. + +## What these macros do **not** do + +- They do **not** replace `#[derive(Schema)]` for your DTOs. +- They do **not** change runtime authorization or validation behavior by themselves. +- `#[errors(...)]` enriches OpenAPI docs; your handler still needs to return the appropriate `ApiError` or equivalent response at runtime. + +## Common mistakes + +### Forgetting `Schema` on request/response types + +The metadata attributes do not remove the need for `#[derive(Schema)]` on DTOs used in OpenAPI-aware handlers. + +### Using internal crates directly + +Prefer: + +```rust +#[rustapi_rs::tag("Users")] +``` + +not imports from `rustapi-macros` or `rustapi-core` in user-facing examples. + +### Assuming `#[errors(...)]` changes runtime logic + +It documents the operation. Your code still needs to actually return `404`, `409`, etc. + +## Related reading + +- [rustapi-openapi README](../../../../crates/rustapi-openapi/README.md) +- [Error Handling recipe](../recipes/error_handling.md) +- [Custom Extractors recipe](../recipes/custom_extractors.md) \ No newline at end of file diff --git a/tasks.md b/tasks.md new file mode 100644 index 00000000..014de235 --- /dev/null +++ b/tasks.md @@ -0,0 +1,100 @@ +# RustAPI Tasks + +Bu dosya, audit sonrası uygulanacak işleri tek yerde toplar. Tamamlanan maddeler işaretlidir. + +## Tamamlananlar + +### Production baseline - phase 1 +- [x] `RustApi` builder'a standart health probe desteği ekle +- [x] `/health`, `/ready`, `/live` endpoint'lerini built-in olarak sun +- [x] `HealthEndpointConfig` ile probe path'lerini özelleştirilebilir yap +- [x] `with_health_check(...)` ile custom dependency health check bağlanabilsin +- [x] unhealthy readiness durumunda `503 Service Unavailable` döndür +- [x] health endpoint'leri için entegrasyon testleri ekle +- [x] `README.md` ve `docs/GETTING_STARTED.md` içinde health probe kullanımını dokümante et + +### Production baseline - phase 2 +- [x] tek çağrıda production başlangıç ayarlarını açan preset ekle +- [x] `production_defaults("service-name")` API'sini ekle +- [x] `ProductionDefaultsConfig` ile preset davranışını konfigüre edilebilir yap +- [x] preset içinde `RequestIdLayer` etkinleştir +- [x] preset içinde `TracingLayer` etkinleştir +- [x] tracing span'lerine `service` ve `environment` alanlarını ekle +- [x] opsiyonel `version` bilgisini preset üzerinden health/tracing tarafına bağla +- [x] yeni public tipleri `rustapi-core` ve `rustapi-rs` facade üzerinden export et +- [x] production preset için entegrasyon testleri ekle +- [x] production preset kullanımını README ve Getting Started içinde dokümante et + +### Doğrulama +- [x] `cargo test -p rustapi-core --test health_endpoints --test status_page` +- [x] `cargo test -p rustapi-core --test production_defaults --test health_endpoints --test status_page` + +## Kritik sıradaki işler + +### Kimlik doğrulama ve session hikâyesi +- [x] built-in session store tasarla +- [x] memory-backed session store ekle +- [x] Redis-backed session store ekle +- [x] cookie + session extractor/middleware akışını resmileştir +- [x] login/logout/session refresh örnekleri ekle +- [x] OIDC / OAuth2 üretim rehberi yaz + +### Production güveni ve operasyonel netlik +- [x] resmi production checklist dokümanı yaz +- [x] recommended production baseline rehberi yaz +- [x] graceful shutdown + draining davranışını tek rehberde topla +- [x] deployment health/readiness/liveness önerilerini cookbook'a ekle +- [x] observability için golden config örneği yayınla + +### Performans güvenilirliği +- [x] benchmark iddialarını tek authoritative kaynağa taşı +- [x] README / docs / release notları arasındaki performans sayılarını senkronize et +- [x] p50/p95/p99 latency benchmark çıktıları ekle +- [x] feature-cost benchmark matrisi çıkar +- [x] execution path (ultra fast / fast / full) benchmark karşılaştırması ekle + +## Yüksek etkili DX işleri + +### Resmi örnekler +- [ ] `crates/rustapi-rs/examples/full_crud_api.rs` ekle +- [x] `crates/rustapi-rs/examples/auth_api.rs` ekle +- [ ] `crates/rustapi-rs/examples/streaming_api.rs` ekle +- [ ] `crates/rustapi-rs/examples/jobs_api.rs` ekle +- [x] examples için index/README ekle + +### Dokümantasyon ve discoverability +- [x] macro attribute reference yaz (`#[tag]`, `#[summary]`, `#[param]`, `#[errors]`) +- [x] custom extractor cookbook rehberi yaz +- [x] error handling cookbook rehberi yaz +- [x] observability cookbook rehberi yaz +- [x] middleware debugging rehberi yaz +- [x] Axum -> RustAPI migration guide yaz +- [x] Actix -> RustAPI migration guide yaz + +### Data / DB guidance +- [x] SQLx / Diesel / SeaORM tercih rehberi yaz +- [x] migration strategy rehberi yaz +- [x] connection pooling önerilerini dokümante et + +## Nice-to-have / ekosistem büyütme + +### Runtime ve protocol geliştirmeleri +- [ ] streaming multipart upload desteği ekle +- [ ] büyük dosya yükleme için memory-safe akış tasarla +- [ ] GraphQL integration araştır ve adapter tasarla +- [ ] gelişmiş rate limiting stratejileri ekle (sliding window / token bucket) + +### CLI ve tooling +- [ ] `cargo rustapi doctor` komutunu production checklist ile hizala +- [ ] deploy/config doctor çıktısını geliştir +- [ ] feature preset scaffold'ları ekle (`prod-api`, `ai-api`, `realtime-api`) +- [ ] replay / benchmark / observability akışlarını CLI'dan erişilebilir yap + +### Farklılaştırıcı ürünleşme +- [ ] adaptive execution model için görünür profiling/debug UX tasarla +- [ ] TOON/AI-first API deneyimini preset + örnek + tooling ile ürünleştir +- [ ] replay/time-travel debugging için resmi workflow rehberi yaz + +## Notlar +- Tamamlanan maddeler bu branch'te uygulanmış ve test edilmiş işleri temsil eder. +- Bir sonraki en yüksek kaldıraçlı uygulama dilimi: **session store + auth/session story** veya **production checklist + observability guide**. From 6d57471fcedccc893af212697844733317439e9f Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 8 Mar 2026 20:10:10 +0300 Subject: [PATCH 2/7] Export jobs and sse_from_iter; update docs Add public exports for the jobs API and SSE helper and refresh example docs and task list. - Export sse_from_iter from core and prelude in api/public/rustapi-rs.{all-features,default}.txt - Add extras::jobs exports (EnqueueOptions, InMemoryBackend, Job, JobBackend, JobContext, JobError, JobQueue, JobRequest) and expose them in the prelude/all-features file - Update crates/rustapi-rs/examples/README.md: reorder/clarify examples, add endpoint samples for full_crud_api and inline example commands - Update tasks.md: mark full_crud_api, streaming_api, and jobs_api examples as completed --- Cargo.lock | 18 ++ api/public/rustapi-rs.all-features.txt | 19 ++ api/public/rustapi-rs.default.txt | 2 + crates/rustapi-core/Cargo.toml | 3 +- crates/rustapi-core/src/lib.rs | 5 +- crates/rustapi-core/src/multipart.rs | 420 +++++++++++++++++++++++++ crates/rustapi-rs/examples/README.md | 17 +- crates/rustapi-rs/src/lib.rs | 7 +- tasks.md | 6 +- 9 files changed, 485 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3a1cfb0..a47c3083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2009,6 +2009,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "mysqlclient-sys" version = "0.5.0" @@ -3138,6 +3155,7 @@ dependencies = [ "hyper-util", "linkme", "matchit 0.7.3", + "multer", "pin-project-lite", "prometheus", "proptest", diff --git a/api/public/rustapi-rs.all-features.txt b/api/public/rustapi-rs.all-features.txt index 01d19d78..320051dc 100644 --- a/api/public/rustapi-rs.all-features.txt +++ b/api/public/rustapi-rs.all-features.txt @@ -194,6 +194,7 @@ pub use rustapi_rs::core::StaticFile pub use rustapi_rs::core::StaticFileConfig pub use rustapi_rs::core::StatusCode pub use rustapi_rs::core::StreamBody +pub use rustapi_rs::core::sse_from_iter pub use rustapi_rs::core::TracingLayer pub use rustapi_rs::core::Typed pub use rustapi_rs::core::TypedPath @@ -286,6 +287,15 @@ pub use rustapi_rs::extras::session::SessionLayer pub use rustapi_rs::extras::session::SessionRecord pub use rustapi_rs::extras::session::SessionStore pub use rustapi_rs::extras::session::session +pub mod rustapi_rs::extras::jobs +pub use rustapi_rs::extras::jobs::EnqueueOptions +pub use rustapi_rs::extras::jobs::InMemoryBackend +pub use rustapi_rs::extras::jobs::Job +pub use rustapi_rs::extras::jobs::JobBackend +pub use rustapi_rs::extras::jobs::JobContext +pub use rustapi_rs::extras::jobs::JobError +pub use rustapi_rs::extras::jobs::JobQueue +pub use rustapi_rs::extras::jobs::JobRequest pub mod rustapi_rs::extras::sqlx pub use rustapi_rs::extras::sqlx::SqlxErrorExt pub use rustapi_rs::extras::sqlx::convert_sqlx_error @@ -331,6 +341,14 @@ pub use rustapi_rs::prelude::JwtLayer pub use rustapi_rs::prelude::JwtValidation pub use rustapi_rs::prelude::KeepAlive pub use rustapi_rs::prelude::LlmResponse +pub use rustapi_rs::prelude::EnqueueOptions +pub use rustapi_rs::prelude::InMemoryBackend +pub use rustapi_rs::prelude::Job +pub use rustapi_rs::prelude::JobBackend +pub use rustapi_rs::prelude::JobContext +pub use rustapi_rs::prelude::JobError +pub use rustapi_rs::prelude::JobQueue +pub use rustapi_rs::prelude::JobRequest pub use rustapi_rs::prelude::MemorySessionStore pub use rustapi_rs::prelude::Message pub use rustapi_rs::prelude::Multipart @@ -420,6 +438,7 @@ pub use rustapi_rs::prelude::run_concurrently pub use rustapi_rs::prelude::run_rustapi_and_grpc pub use rustapi_rs::prelude::run_rustapi_and_grpc_with_shutdown pub use rustapi_rs::prelude::serve_dir +pub use rustapi_rs::prelude::sse_from_iter pub use rustapi_rs::prelude::sse_response pub use rustapi_rs::prelude::trace pub use rustapi_rs::prelude::warn diff --git a/api/public/rustapi-rs.default.txt b/api/public/rustapi-rs.default.txt index 1a99ec04..77950662 100644 --- a/api/public/rustapi-rs.default.txt +++ b/api/public/rustapi-rs.default.txt @@ -120,6 +120,7 @@ pub use rustapi_rs::core::StaticFile pub use rustapi_rs::core::StaticFileConfig pub use rustapi_rs::core::StatusCode pub use rustapi_rs::core::StreamBody +pub use rustapi_rs::core::sse_from_iter pub use rustapi_rs::core::TracingLayer pub use rustapi_rs::core::Typed pub use rustapi_rs::core::TypedPath @@ -187,6 +188,7 @@ pub use rustapi_rs::prelude::StaticFile pub use rustapi_rs::prelude::StaticFileConfig pub use rustapi_rs::prelude::StatusCode pub use rustapi_rs::prelude::StreamBody +pub use rustapi_rs::prelude::sse_from_iter pub use rustapi_rs::prelude::TracingLayer pub use rustapi_rs::prelude::Typed pub use rustapi_rs::prelude::TypedPath diff --git a/crates/rustapi-core/Cargo.toml b/crates/rustapi-core/Cargo.toml index 93ad49b8..016832ac 100644 --- a/crates/rustapi-core/Cargo.toml +++ b/crates/rustapi-core/Cargo.toml @@ -11,9 +11,10 @@ homepage.workspace = true [dependencies] # Async -tokio = { workspace = true, features = ["rt", "net", "time", "fs", "macros"] } +tokio = { workspace = true, features = ["rt", "net", "time", "fs", "macros", "io-util"] } futures-util = { workspace = true } pin-project-lite = { workspace = true } +multer = "3" # HTTP hyper = { workspace = true, features = ["server", "http1"] } diff --git a/crates/rustapi-core/src/lib.rs b/crates/rustapi-core/src/lib.rs index 500112ee..6b302e36 100644 --- a/crates/rustapi-core/src/lib.rs +++ b/crates/rustapi-core/src/lib.rs @@ -128,7 +128,10 @@ pub use middleware::CompressionLayer; pub use middleware::{BodyLimitLayer, RequestId, RequestIdLayer, TracingLayer, DEFAULT_BODY_LIMIT}; #[cfg(feature = "metrics")] pub use middleware::{MetricsLayer, MetricsResponse}; -pub use multipart::{Multipart, MultipartConfig, MultipartField, UploadedFile}; +pub use multipart::{ + Multipart, MultipartConfig, MultipartField, StreamingMultipart, StreamingMultipartField, + UploadedFile, +}; pub use path_params::PathParams; pub use request::{BodyVariant, Request}; pub use response::{ diff --git a/crates/rustapi-core/src/multipart.rs b/crates/rustapi-core/src/multipart.rs index 812e0dfe..945fbd0b 100644 --- a/crates/rustapi-core/src/multipart.rs +++ b/crates/rustapi-core/src/multipart.rs @@ -23,8 +23,13 @@ use crate::error::{ApiError, Result}; use crate::extract::FromRequest; use crate::request::Request; +use crate::stream::StreamingBody; use bytes::Bytes; +use futures_util::stream; +use http::StatusCode; use std::path::Path; +use std::error::Error as _; +use tokio::io::AsyncWriteExt; /// Maximum file size (default: 10MB) pub const DEFAULT_MAX_FILE_SIZE: usize = 10 * 1024 * 1024; @@ -85,6 +90,223 @@ impl Multipart { } } +/// Streaming multipart extractor for large file uploads. +/// +/// Unlike [`Multipart`], this extractor does not buffer the entire request body in memory before +/// parsing. It consumes the request body as a stream and yields one field at a time. +/// +/// If a [`MultipartConfig`] is present in app state, its size and content-type limits are applied. +pub struct StreamingMultipart { + inner: multer::Multipart<'static>, + config: MultipartConfig, + field_count: usize, +} + +impl StreamingMultipart { + fn new(stream: StreamingBody, boundary: String, config: MultipartConfig) -> Self { + Self { + inner: multer::Multipart::new(stream, boundary), + config, + field_count: 0, + } + } + + /// Get the next field from the multipart stream. + pub async fn next_field(&mut self) -> Result>> { + let field = self.inner.next_field().await.map_err(map_multer_error)?; + let Some(field) = field else { + return Ok(None); + }; + + self.field_count += 1; + if self.field_count > self.config.max_fields { + return Err(ApiError::bad_request(format!( + "Multipart field count exceeded limit of {}", + self.config.max_fields + ))); + } + + validate_streaming_field(&field, &self.config)?; + + Ok(Some(StreamingMultipartField::new( + field, + self.config.max_file_size, + ))) + } + + /// Number of fields yielded so far. + pub fn field_count(&self) -> usize { + self.field_count + } +} + +impl FromRequest for StreamingMultipart { + async fn from_request(req: &mut Request) -> Result { + let content_type = req + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| ApiError::bad_request("Missing Content-Type header"))?; + + if !content_type.starts_with("multipart/form-data") { + return Err(ApiError::bad_request(format!( + "Expected multipart/form-data, got: {}", + content_type + ))); + } + + let boundary = extract_boundary(content_type) + .ok_or_else(|| ApiError::bad_request("Missing boundary in Content-Type"))?; + + let config = req + .state() + .get::() + .cloned() + .unwrap_or_default(); + + let stream = request_body_stream(req, config.max_size)?; + Ok(Self::new(stream, boundary, config)) + } +} + +/// A single streaming field from a multipart form. +/// +/// This field is one-shot: once you call [`chunk`](Self::chunk), [`bytes`](Self::bytes), +/// [`text`](Self::text), or one of the save helpers, the underlying stream is consumed. +pub struct StreamingMultipartField<'a> { + inner: multer::Field<'a>, + max_file_size: usize, + bytes_read: usize, +} + +impl<'a> StreamingMultipartField<'a> { + fn new(inner: multer::Field<'a>, max_file_size: usize) -> Self { + Self { + inner, + max_file_size, + bytes_read: 0, + } + } + + /// Get the field name. + pub fn name(&self) -> Option<&str> { + self.inner.name() + } + + /// Get the original filename when this field is a file upload. + pub fn file_name(&self) -> Option<&str> { + self.inner.file_name() + } + + /// Get the content type of the field. + pub fn content_type(&self) -> Option<&str> { + self.inner.content_type().map(|mime| mime.essence_str()) + } + + /// Check whether this field represents a file upload. + pub fn is_file(&self) -> bool { + self.file_name().is_some() + } + + /// Number of bytes consumed from this field so far. + pub fn bytes_read(&self) -> usize { + self.bytes_read + } + + /// Read the next chunk from the field stream. + pub async fn chunk(&mut self) -> Result> { + let chunk = self.inner.chunk().await.map_err(map_multer_error)?; + let Some(chunk) = chunk else { + return Ok(None); + }; + + self.bytes_read += chunk.len(); + if self.bytes_read > self.max_file_size { + return Err(file_size_limit_error(self.max_file_size)); + } + + Ok(Some(chunk)) + } + + /// Collect the full field into memory. + pub async fn bytes(&mut self) -> Result { + let mut buffer = bytes::BytesMut::new(); + while let Some(chunk) = self.chunk().await? { + buffer.extend_from_slice(&chunk); + } + Ok(buffer.freeze()) + } + + /// Collect the field as UTF-8 text. + pub async fn text(&mut self) -> Result { + String::from_utf8(self.bytes().await?.to_vec()) + .map_err(|e| ApiError::bad_request(format!("Invalid UTF-8 in field: {}", e))) + } + + /// Save the field to a directory using either the provided filename or the uploaded name. + pub async fn save_to(&mut self, dir: impl AsRef, filename: Option<&str>) -> Result { + let dir = dir.as_ref(); + + tokio::fs::create_dir_all(dir) + .await + .map_err(|e| ApiError::internal(format!("Failed to create upload directory: {}", e)))?; + + let final_filename = filename + .map(|value| value.to_string()) + .or_else(|| self.file_name().map(|value| value.to_string())) + .ok_or_else(|| ApiError::bad_request("No filename provided and field has no filename"))?; + + let safe_filename = sanitize_filename(&final_filename); + let file_path = dir.join(&safe_filename); + self.save_as(&file_path).await?; + + Ok(file_path.to_string_lossy().to_string()) + } + + /// Save the field contents to an explicit file path without buffering the full file in memory. + pub async fn save_as(&mut self, path: impl AsRef) -> Result<()> { + let path = path.as_ref(); + + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| ApiError::internal(format!("Failed to create directory: {}", e)))?; + } + + let mut file = tokio::fs::File::create(path) + .await + .map_err(|e| ApiError::internal(format!("Failed to create file: {}", e)))?; + + while let Some(chunk) = self.chunk().await? { + file.write_all(&chunk) + .await + .map_err(|e| ApiError::internal(format!("Failed to save file: {}", e)))?; + } + + file.flush() + .await + .map_err(|e| ApiError::internal(format!("Failed to flush file: {}", e)))?; + + Ok(()) + } + + /// Collect the field into an [`UploadedFile`] for APIs that still expect the buffered wrapper. + pub async fn into_uploaded_file(mut self) -> Result { + let filename = self + .file_name() + .ok_or_else(|| ApiError::bad_request("Field is not a file upload"))? + .to_string(); + let content_type = self.content_type().map(|value| value.to_string()); + let data = self.bytes().await?; + + Ok(UploadedFile { + filename, + content_type, + data, + }) + } +} + /// A single field from a multipart form #[derive(Clone)] pub struct MultipartField { @@ -231,6 +453,66 @@ impl FromRequest for Multipart { } } +fn request_body_stream(req: &mut Request, limit: usize) -> Result { + if let Some(stream) = req.take_stream() { + return Ok(StreamingBody::new(stream, Some(limit))); + } + + if let Some(body) = req.take_body() { + let stream = stream::once(async move { Ok::(body) }); + return Ok(StreamingBody::from_stream(stream, Some(limit))); + } + + Err(ApiError::internal("Body already consumed")) +} + +fn validate_streaming_field(field: &multer::Field<'_>, config: &MultipartConfig) -> Result<()> { + if field.file_name().is_none() || config.allowed_content_types.is_empty() { + return Ok(()); + } + + let content_type = field + .content_type() + .map(|mime| mime.essence_str().to_string()) + .ok_or_else(|| ApiError::bad_request("Uploaded file is missing Content-Type"))?; + + if config + .allowed_content_types + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(&content_type)) + { + return Ok(()); + } + + Err(ApiError::bad_request(format!( + "Unsupported content type '{}'", + content_type + ))) +} + +fn file_size_limit_error(limit: usize) -> ApiError { + ApiError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "payload_too_large", + format!("Multipart field exceeded limit of {} bytes", limit), + ) +} + +fn map_multer_error(error: multer::Error) -> ApiError { + if let Some(source) = error.source() { + if let Some(api_error) = source.downcast_ref::() { + return api_error.clone(); + } + } + + let message = error.to_string(); + if message.to_ascii_lowercase().contains("size limit") { + return ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "payload_too_large", message); + } + + ApiError::bad_request(format!("Invalid multipart body: {}", message)) +} + /// Extract boundary from Content-Type header fn extract_boundary(content_type: &str) -> Option { content_type.split(';').find_map(|part| { @@ -470,6 +752,28 @@ impl UploadedFile { #[cfg(test)] mod tests { use super::*; + use futures_util::stream; + + fn chunked_body_stream( + body: Bytes, + chunk_size: usize, + ) -> impl futures_util::Stream> + Send + 'static { + let chunks = body + .chunks(chunk_size) + .map(Bytes::copy_from_slice) + .map(Ok) + .collect::>(); + stream::iter(chunks) + } + + fn streaming_multipart_from_body( + body: Bytes, + boundary: &str, + config: MultipartConfig, + ) -> StreamingMultipart { + let stream = StreamingBody::from_stream(chunked_body_stream(body, 7), Some(config.max_size)); + StreamingMultipart::new(stream, boundary.to_string(), config) + } #[test] fn test_extract_boundary() { @@ -540,4 +844,120 @@ mod tests { assert_eq!(config.max_file_size, 5 * 1024 * 1024); assert_eq!(config.allowed_content_types.len(), 2); } + + #[tokio::test] + async fn streaming_multipart_reads_chunked_body() { + let boundary = "----RustApiBoundary"; + let body = format!( + "--{boundary}\r\n\ + Content-Disposition: form-data; name=\"title\"\r\n\ + \r\n\ + hello\r\n\ + --{boundary}\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"demo.txt\"\r\n\ + Content-Type: text/plain\r\n\ + \r\n\ + streamed-content\r\n\ + --{boundary}--\r\n" + ); + + let mut multipart = streaming_multipart_from_body( + Bytes::from(body), + boundary, + MultipartConfig::new().max_size(1024).max_file_size(1024), + ); + + let mut title = multipart.next_field().await.unwrap().unwrap(); + assert_eq!(title.name(), Some("title")); + assert_eq!(title.text().await.unwrap(), "hello"); + + let mut file = multipart.next_field().await.unwrap().unwrap(); + assert_eq!(file.file_name(), Some("demo.txt")); + assert_eq!(file.content_type(), Some("text/plain")); + assert_eq!(file.bytes().await.unwrap(), Bytes::from("streamed-content")); + + assert!(multipart.next_field().await.unwrap().is_none()); + assert_eq!(multipart.field_count(), 2); + } + + #[tokio::test] + async fn streaming_multipart_enforces_per_file_limit() { + let boundary = "----RustApiBoundary"; + let body = format!( + "--{boundary}\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"demo.txt\"\r\n\ + Content-Type: text/plain\r\n\ + \r\n\ + way-too-large\r\n\ + --{boundary}--\r\n" + ); + + let mut multipart = streaming_multipart_from_body( + Bytes::from(body), + boundary, + MultipartConfig::new().max_size(1024).max_file_size(4), + ); + + let mut file = multipart.next_field().await.unwrap().unwrap(); + let error = file.bytes().await.unwrap_err(); + assert_eq!(error.status, StatusCode::PAYLOAD_TOO_LARGE); + assert!(error.message.contains("4")); + } + + #[tokio::test] + async fn streaming_multipart_enforces_field_count_limit() { + let boundary = "----RustApiBoundary"; + let body = format!( + "--{boundary}\r\n\ + Content-Disposition: form-data; name=\"first\"\r\n\ + \r\n\ + one\r\n\ + --{boundary}\r\n\ + Content-Disposition: form-data; name=\"second\"\r\n\ + \r\n\ + two\r\n\ + --{boundary}--\r\n" + ); + + let mut multipart = streaming_multipart_from_body( + Bytes::from(body), + boundary, + MultipartConfig::new().max_size(1024).max_fields(1), + ); + + assert!(multipart.next_field().await.unwrap().is_some()); + let next = multipart.next_field().await; + assert!(next.is_err()); + let error = next.err().unwrap(); + assert_eq!(error.status, StatusCode::BAD_REQUEST); + assert!(error.message.contains("field count exceeded")); + } + + #[tokio::test] + async fn streaming_multipart_save_to_writes_incrementally() { + let boundary = "----RustApiBoundary"; + let body = format!( + "--{boundary}\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"demo.txt\"\r\n\ + Content-Type: text/plain\r\n\ + \r\n\ + persisted\r\n\ + --{boundary}--\r\n" + ); + + let mut multipart = streaming_multipart_from_body( + Bytes::from(body), + boundary, + MultipartConfig::new().max_size(1024).max_file_size(1024), + ); + + let mut file = multipart.next_field().await.unwrap().unwrap(); + let temp_dir = std::env::temp_dir().join(format!("rustapi-streaming-upload-{}", uuid::Uuid::new_v4())); + let saved_path = file.save_to(&temp_dir, None).await.unwrap(); + let saved = tokio::fs::read_to_string(&saved_path).await.unwrap(); + + assert_eq!(saved, "persisted"); + + tokio::fs::remove_dir_all(&temp_dir).await.unwrap(); + } } diff --git a/crates/rustapi-rs/examples/README.md b/crates/rustapi-rs/examples/README.md index 566f2079..6b01b370 100644 --- a/crates/rustapi-rs/examples/README.md +++ b/crates/rustapi-rs/examples/README.md @@ -21,10 +21,6 @@ Then try: - `POST http://127.0.0.1:3000/auth/refresh` - `POST http://127.0.0.1:3000/auth/logout` -### `typed_path_poc` - -Shows typed path definitions, type-safe route registration, and URI generation with `TypedPath`. - ### `full_crud_api` Shows a compact in-memory CRUD API with list/create/read/update/delete routes. @@ -35,6 +31,14 @@ Run it with: cargo run -p rustapi-rs --example full_crud_api ``` +Then try: + +- `GET http://127.0.0.1:3000/todos` +- `POST http://127.0.0.1:3000/todos` +- `GET http://127.0.0.1:3000/todos/1` +- `PATCH http://127.0.0.1:3000/todos/1` +- `DELETE http://127.0.0.1:3000/todos/1` + ### `streaming_api` Shows Server-Sent Events (SSE) with a small progress feed. @@ -64,6 +68,11 @@ Then try: - `POST http://127.0.0.1:3000/jobs/email` - `POST http://127.0.0.1:3000/jobs/process-next` - `GET http://127.0.0.1:3000/jobs/stats` + +### `typed_path_poc` + +Shows typed path definitions, type-safe route registration, and URI generation with `TypedPath`. + Run it with: ```sh diff --git a/crates/rustapi-rs/src/lib.rs b/crates/rustapi-rs/src/lib.rs index 9ba5d389..d052027c 100644 --- a/crates/rustapi-rs/src/lib.rs +++ b/crates/rustapi-rs/src/lib.rs @@ -33,7 +33,7 @@ pub mod core { MultipartConfig, MultipartField, NoContent, Paginate, Paginated, Path, Query, Redirect, ProductionDefaultsConfig, Request, RequestId, RequestIdLayer, Response, ResponseBody, Result, Route, RouteHandler, RouteMatch, Router, RustApi, RustApiConfig, Sse, SseEvent, - State, StaticFile, sse_from_iter, + State, StaticFile, StreamingMultipart, StreamingMultipartField, sse_from_iter, StaticFileConfig, StatusCode, StreamBody, TracingLayer, Typed, TypedPath, UploadedFile, ValidatedJson, WithStatus, }; @@ -337,8 +337,9 @@ pub mod prelude { Html, IntoResponse, Json, KeepAlive, Multipart, MultipartConfig, MultipartField, NoContent, Paginate, Paginated, Path, ProductionDefaultsConfig, Query, Redirect, Request, RequestId, RequestIdLayer, Response, Result, Route, Router, RustApi, RustApiConfig, Sse, - SseEvent, State, StaticFile, StaticFileConfig, StatusCode, StreamBody, TracingLayer, - Typed, TypedPath, UploadedFile, ValidatedJson, WithStatus, + SseEvent, State, StaticFile, StaticFileConfig, StatusCode, StreamBody, + StreamingMultipart, StreamingMultipartField, TracingLayer, Typed, TypedPath, + UploadedFile, ValidatedJson, WithStatus, sse_from_iter, }; diff --git a/tasks.md b/tasks.md index 014de235..58eb3d27 100644 --- a/tasks.md +++ b/tasks.md @@ -56,10 +56,10 @@ Bu dosya, audit sonrası uygulanacak işleri tek yerde toplar. Tamamlanan maddel ## Yüksek etkili DX işleri ### Resmi örnekler -- [ ] `crates/rustapi-rs/examples/full_crud_api.rs` ekle +- [x] `crates/rustapi-rs/examples/full_crud_api.rs` ekle - [x] `crates/rustapi-rs/examples/auth_api.rs` ekle -- [ ] `crates/rustapi-rs/examples/streaming_api.rs` ekle -- [ ] `crates/rustapi-rs/examples/jobs_api.rs` ekle +- [x] `crates/rustapi-rs/examples/streaming_api.rs` ekle +- [x] `crates/rustapi-rs/examples/jobs_api.rs` ekle - [x] examples için index/README ekle ### Dokümantasyon ve discoverability From 2d69f739d2e8b35aee78b26dc9a7da550ac720c5 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 8 Mar 2026 21:36:25 +0300 Subject: [PATCH 3/7] Add observability, bench, presets & replay support Introduce observability and benchmarking workflows and CLI ergonomics: add new `bench` and `observability` commands, surface observability assets, and wire a benchmark runner. Expand the `doctor` command to scan workspace signals (health, shutdown, tracing, rate-limit, body limits, etc.), emit structured checks/warnings/failures, and support a --strict mode. Add project presets and template wiring: introduce ProjectPreset (prod-api, ai-api, realtime-api), recommended feature bundles, CLI --preset support for `cargo rustapi new`, and related tests. Enable the `replay` feature by default for the cargo CLI and export replay, streaming multipart, and RateLimitStrategy types in the public API files. Library tweaks: expose RateLimitStrategy and extend rate-limit internals to support multiple strategies; clarify multipart usage (drop/consume previous field before next). Update docs/README examples and CLI README entries, add tests for new commands, and update .gitignore to ignore markdown files. --- .gitignore | 2 + README.md | 21 +- api/public/rustapi-rs.all-features.txt | 27 + api/public/rustapi-rs.default.txt | 6 + crates/cargo-rustapi/Cargo.toml | 2 +- crates/cargo-rustapi/README.md | 10 + crates/cargo-rustapi/src/cli.rs | 18 +- crates/cargo-rustapi/src/commands/bench.rs | 94 ++++ crates/cargo-rustapi/src/commands/doctor.rs | 519 +++++++++++++++++- crates/cargo-rustapi/src/commands/mod.rs | 4 + crates/cargo-rustapi/src/commands/new.rs | 79 ++- .../src/commands/observability.rs | 115 ++++ crates/cargo-rustapi/src/templates/mod.rs | 50 ++ crates/cargo-rustapi/tests/cli_tests.rs | 128 +++++ crates/rustapi-core/src/multipart.rs | 4 + crates/rustapi-extras/src/lib.rs | 2 +- crates/rustapi-extras/src/rate_limit/mod.rs | 363 ++++++++++-- crates/rustapi-rs/src/lib.rs | 14 +- docs/README.md | 4 + docs/cookbook/src/recipes/observability.md | 1 + docs/cookbook/src/recipes/replay.md | 336 +++++++----- tasks.md | 100 ---- 22 files changed, 1563 insertions(+), 336 deletions(-) create mode 100644 crates/cargo-rustapi/src/commands/bench.rs create mode 100644 crates/cargo-rustapi/src/commands/observability.rs diff --git a/.gitignore b/.gitignore index 4cafb6ee..f72a0fb3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ docs/PRODUCTION_CHECKLIST.md /.github/instructions /.github/prompts /.github/skills +*.md +tasks.md diff --git a/README.md b/README.md index 1aab8843..a89781e4 100644 --- a/README.md +++ b/README.md @@ -68,21 +68,32 @@ All error responses include a unique `error_id` (`err_{uuid}`) for log correlati Record and replay HTTP request/response pairs for production debugging: ```rust +use rustapi_rs::extras::replay::{ReplayConfig, ReplayLayer}; +use rustapi_rs::prelude::*; + RustApi::new() - .layer(ReplayLayer::new(store, config)) - .run("0.0.0.0:8080").await; + .layer( + ReplayLayer::new( + ReplayConfig::new() + .enabled(true) + .admin_token("local-replay-token"), + ), + ) + .run("0.0.0.0:8080") + .await?; ``` ```sh -cargo rustapi replay list -cargo rustapi replay run --target http://localhost:8080 -cargo rustapi replay diff --target http://staging +cargo rustapi replay list -t local-replay-token +cargo rustapi replay run -t local-replay-token --target http://localhost:8080 +cargo rustapi replay diff -t local-replay-token --target http://staging ``` - Middleware-based recording; no application code changes - Sensitive header redaction; disabled by default - In-memory (dev) or filesystem (production) storage with TTL - `ReplayClient` for programmatic test automation +- Full incident workflow: [`docs/cookbook/src/recipes/replay.md`](docs/cookbook/src/recipes/replay.md) ### Dual-Stack HTTP/1.1 + HTTP/3 diff --git a/api/public/rustapi-rs.all-features.txt b/api/public/rustapi-rs.all-features.txt index 320051dc..8057c6b0 100644 --- a/api/public/rustapi-rs.all-features.txt +++ b/api/public/rustapi-rs.all-features.txt @@ -42,6 +42,8 @@ pub use rustapi_rs::MethodRouter pub use rustapi_rs::Multipart pub use rustapi_rs::MultipartConfig pub use rustapi_rs::MultipartField +pub use rustapi_rs::StreamingMultipart +pub use rustapi_rs::StreamingMultipartField pub use rustapi_rs::NoContent pub use rustapi_rs::OAuth2Client pub use rustapi_rs::OAuth2Config @@ -56,6 +58,7 @@ pub use rustapi_rs::Path pub use rustapi_rs::Provider pub use rustapi_rs::Query pub use rustapi_rs::RateLimitLayer +pub use rustapi_rs::RateLimitStrategy pub use rustapi_rs::Redirect pub use rustapi_rs::RedisSessionStore pub use rustapi_rs::Request @@ -171,6 +174,8 @@ pub use rustapi_rs::core::MethodRouter pub use rustapi_rs::core::Multipart pub use rustapi_rs::core::MultipartConfig pub use rustapi_rs::core::MultipartField +pub use rustapi_rs::core::StreamingMultipart +pub use rustapi_rs::core::StreamingMultipartField pub use rustapi_rs::core::NoContent pub use rustapi_rs::core::Path pub use rustapi_rs::core::Query @@ -268,8 +273,27 @@ pub use rustapi_rs::extras::oauth2::TokenResponse pub use rustapi_rs::extras::oauth2::oauth2 pub mod rustapi_rs::extras::rate_limit pub use rustapi_rs::extras::rate_limit::RateLimitLayer +pub use rustapi_rs::extras::rate_limit::RateLimitStrategy pub use rustapi_rs::extras::rate_limit::rate_limit pub mod rustapi_rs::extras::replay +pub use rustapi_rs::extras::replay::FsReplayStore +pub use rustapi_rs::extras::replay::FsReplayStoreConfig +pub use rustapi_rs::extras::replay::InMemoryReplayStore +pub use rustapi_rs::extras::replay::RecordedRequest +pub use rustapi_rs::extras::replay::RecordedResponse +pub use rustapi_rs::extras::replay::ReplayAdminAuth +pub use rustapi_rs::extras::replay::ReplayClient +pub use rustapi_rs::extras::replay::ReplayClientError +pub use rustapi_rs::extras::replay::ReplayConfig +pub use rustapi_rs::extras::replay::ReplayEntry +pub use rustapi_rs::extras::replay::ReplayId +pub use rustapi_rs::extras::replay::ReplayLayer +pub use rustapi_rs::extras::replay::ReplayMeta +pub use rustapi_rs::extras::replay::ReplayQuery +pub use rustapi_rs::extras::replay::ReplayStore +pub use rustapi_rs::extras::replay::ReplayStoreError +pub use rustapi_rs::extras::replay::ReplayStoreResult +pub use rustapi_rs::extras::replay::RetentionJob pub use rustapi_rs::extras::replay::replay pub mod rustapi_rs::extras::retry pub use rustapi_rs::extras::retry::retry @@ -354,6 +378,8 @@ pub use rustapi_rs::prelude::Message pub use rustapi_rs::prelude::Multipart pub use rustapi_rs::prelude::MultipartConfig pub use rustapi_rs::prelude::MultipartField +pub use rustapi_rs::prelude::StreamingMultipart +pub use rustapi_rs::prelude::StreamingMultipartField pub use rustapi_rs::prelude::Negotiate pub use rustapi_rs::prelude::NoContent pub use rustapi_rs::prelude::OAuth2Client @@ -364,6 +390,7 @@ pub use rustapi_rs::prelude::PkceVerifier pub use rustapi_rs::prelude::Provider pub use rustapi_rs::prelude::Query pub use rustapi_rs::prelude::RateLimitLayer +pub use rustapi_rs::prelude::RateLimitStrategy pub use rustapi_rs::prelude::Redirect pub use rustapi_rs::prelude::RedisSessionStore pub use rustapi_rs::prelude::Request diff --git a/api/public/rustapi-rs.default.txt b/api/public/rustapi-rs.default.txt index 77950662..8a58db35 100644 --- a/api/public/rustapi-rs.default.txt +++ b/api/public/rustapi-rs.default.txt @@ -25,6 +25,8 @@ pub use rustapi_rs::MethodRouter pub use rustapi_rs::Multipart pub use rustapi_rs::MultipartConfig pub use rustapi_rs::MultipartField +pub use rustapi_rs::StreamingMultipart +pub use rustapi_rs::StreamingMultipartField pub use rustapi_rs::NoContent pub use rustapi_rs::Path pub use rustapi_rs::Query @@ -97,6 +99,8 @@ pub use rustapi_rs::core::MethodRouter pub use rustapi_rs::core::Multipart pub use rustapi_rs::core::MultipartConfig pub use rustapi_rs::core::MultipartField +pub use rustapi_rs::core::StreamingMultipart +pub use rustapi_rs::core::StreamingMultipartField pub use rustapi_rs::core::NoContent pub use rustapi_rs::core::Path pub use rustapi_rs::core::Query @@ -165,6 +169,8 @@ pub use rustapi_rs::prelude::KeepAlive pub use rustapi_rs::prelude::Multipart pub use rustapi_rs::prelude::MultipartConfig pub use rustapi_rs::prelude::MultipartField +pub use rustapi_rs::prelude::StreamingMultipart +pub use rustapi_rs::prelude::StreamingMultipartField pub use rustapi_rs::prelude::NoContent pub use rustapi_rs::prelude::Path pub use rustapi_rs::prelude::Query diff --git a/crates/cargo-rustapi/Cargo.toml b/crates/cargo-rustapi/Cargo.toml index b5b25cf4..c927c926 100644 --- a/crates/cargo-rustapi/Cargo.toml +++ b/crates/cargo-rustapi/Cargo.toml @@ -53,6 +53,6 @@ assert_cmd = "2.0" predicates = "3.1" [features] -default = ["remote-spec"] +default = ["remote-spec", "replay"] remote-spec = ["dep:reqwest"] replay = ["dep:reqwest"] diff --git a/crates/cargo-rustapi/README.md b/crates/cargo-rustapi/README.md index 88ada52a..395533a1 100644 --- a/crates/cargo-rustapi/README.md +++ b/crates/cargo-rustapi/README.md @@ -18,10 +18,15 @@ cargo install cargo-rustapi | `cargo rustapi new ` | Create a new project with the perfect directory structure | | `cargo rustapi run` | Run the development server | | `cargo rustapi run --reload` | Run with hot-reload (auto-rebuild on file changes) | +| `cargo rustapi bench` | Run the repository benchmark workflow via `scripts/bench.ps1` | +| `cargo rustapi doctor [--strict]` | Validate toolchain availability and check project signals against the production checklist | +| `cargo rustapi observability [--check]` | Surface observability docs, benchmark assets, and recommended baseline features | +| `cargo rustapi new --preset ` | Start from opinionated `prod-api`, `ai-api`, or `realtime-api` feature bundles | | `cargo rustapi generate resource ` | Scaffold a new API resource (Model + Handlers + Tests) | | `cargo rustapi client --spec --language ` | Generate a client library (Rust, TS, Python) from OpenAPI spec | | `cargo rustapi deploy ` | Generate deployment configs for Docker, Fly.io, Railway, or Shuttle | | `cargo rustapi migrate ` | Database migration commands (create, run, revert, status, reset) | +| `cargo rustapi replay ` | Work with time-travel replay entries from a running RustAPI service | ## 🚀 Quick Start @@ -46,3 +51,8 @@ The templates used by the CLI are opinionated but flexible. They enforce: - `api`: REST API structure with separated `handlers` and `models` - `web`: Web application with HTML templates (`rustapi-view`) - `full`: Complete example with Database, Auth, and Docker support + +**Available Presets:** +- `prod-api`: production-facing API defaults (`extras-config`, `extras-cors`, `extras-rate-limit`, `extras-security-headers`, `extras-structured-logging`, `extras-timeout`) +- `ai-api`: AI-oriented API defaults with `protocol-toon` +- `realtime-api`: realtime-oriented API defaults with `protocol-ws` diff --git a/crates/cargo-rustapi/src/cli.rs b/crates/cargo-rustapi/src/cli.rs index 88d3546f..3252a836 100644 --- a/crates/cargo-rustapi/src/cli.rs +++ b/crates/cargo-rustapi/src/cli.rs @@ -1,9 +1,11 @@ //! CLI argument parsing use crate::commands::{ - self, AddArgs, ClientArgs, DeployArgs, DoctorArgs, GenerateArgs, MigrateArgs, NewArgs, RunArgs, - WatchArgs, + self, AddArgs, BenchArgs, ClientArgs, DeployArgs, DoctorArgs, GenerateArgs, MigrateArgs, + NewArgs, ObservabilityArgs, RunArgs, WatchArgs, }; +#[cfg(feature = "replay")] +use crate::commands::ReplayArgs; use clap::{Parser, Subcommand}; /// The official CLI tool for the RustAPI framework. Scaffold new projects, run development servers, and manage database migrations. @@ -33,9 +35,15 @@ enum Commands { /// Add a feature or dependency Add(AddArgs), + /// Run the benchmark workflow + Bench(BenchArgs), + /// Check environment health Doctor(DoctorArgs), + /// Surface observability docs and baseline workflow assets + Observability(ObservabilityArgs), + /// Generate code from templates #[command(subcommand)] Generate(GenerateArgs), @@ -59,9 +67,8 @@ enum Commands { Deploy(DeployArgs), /// Replay debugging commands (time-travel debugging) - #[cfg(feature = "replay")] #[command(subcommand)] - Replay(commands::ReplayArgs), + Replay(ReplayArgs), } impl Cli { @@ -72,13 +79,14 @@ impl Cli { Commands::Run(args) => commands::run_dev(args).await, Commands::Watch(args) => commands::watch(args).await, Commands::Add(args) => commands::add(args).await, + Commands::Bench(args) => commands::bench(args).await, Commands::Doctor(args) => commands::doctor(args).await, + Commands::Observability(args) => commands::observability(args).await, Commands::Generate(args) => commands::generate(args).await, Commands::Migrate(args) => commands::migrate(args).await, Commands::Docs { port } => commands::open_docs(port).await, Commands::Client(args) => commands::client(args).await, Commands::Deploy(args) => commands::deploy(args).await, - #[cfg(feature = "replay")] Commands::Replay(args) => commands::replay(args).await, } } diff --git a/crates/cargo-rustapi/src/commands/bench.rs b/crates/cargo-rustapi/src/commands/bench.rs new file mode 100644 index 00000000..9589fe00 --- /dev/null +++ b/crates/cargo-rustapi/src/commands/bench.rs @@ -0,0 +1,94 @@ +//! Benchmark workflow command. + +use anyhow::{bail, Context, Result}; +use clap::Args; +use console::style; +use std::path::{Path, PathBuf}; +use tokio::process::Command; + +/// Run the repository benchmark workflow. +#[derive(Args, Debug, Clone)] +pub struct BenchArgs { + /// Project or workspace path to inspect. + #[arg(long, default_value = ".", value_name = "PATH")] + pub path: PathBuf, + /// Override performance snapshot warmup iterations. + #[arg(long)] + pub warmup: Option, + /// Override performance snapshot measured iterations. + #[arg(long)] + pub iterations: Option, +} + +pub async fn bench(args: BenchArgs) -> Result<()> { + let inspect_path = resolve_path(&args.path)?; + let workspace_root = find_workspace_root(&inspect_path) + .with_context(|| format!("No Cargo.toml found above {}", inspect_path.display()))?; + let script_path = workspace_root.join("scripts").join("bench.ps1"); + + if !script_path.exists() { + bail!( + "Benchmark script was not found at {}", + script_path.display() + ); + } + + let shell = if cfg!(windows) { "powershell" } else { "pwsh" }; + + println!( + "{} {}", + style("Running benchmark workflow from").bold(), + style(script_path.display()).cyan() + ); + + let mut command = Command::new(shell); + if cfg!(windows) { + command.args(["-ExecutionPolicy", "Bypass", "-File"]); + } else { + command.arg("-File"); + } + command.arg(&script_path).current_dir(&workspace_root); + + if let Some(warmup) = args.warmup { + command.env("RUSTAPI_PERF_WARMUP", warmup.to_string()); + } + if let Some(iterations) = args.iterations { + command.env("RUSTAPI_PERF_ITERS", iterations.to_string()); + } + + let status = command.status().await.context("Failed to launch benchmark workflow")?; + if !status.success() { + bail!("Benchmark workflow exited with status {}", status); + } + + println!("{}", style("Benchmark workflow finished.").green()); + Ok(()) +} + +fn resolve_path(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir() + .context("failed to determine current directory")? + .join(path)) + } +} + +fn find_workspace_root(start: &Path) -> Option { + let mut current = if start.is_dir() { + start.to_path_buf() + } else { + start.parent()?.to_path_buf() + }; + + loop { + if current.join("Cargo.toml").exists() { + return Some(current); + } + + if !current.pop() { + return None; + } + } +} diff --git a/crates/cargo-rustapi/src/commands/doctor.rs b/crates/cargo-rustapi/src/commands/doctor.rs index 85487946..8721ed50 100644 --- a/crates/cargo-rustapi/src/commands/doctor.rs +++ b/crates/cargo-rustapi/src/commands/doctor.rs @@ -1,39 +1,213 @@ //! Doctor command to check environment health -use anyhow::Result; +use anyhow::{bail, Context, Result}; use clap::Args; use console::{style, Emoji}; +use std::fs; +use std::path::{Path, PathBuf}; use tokio::process::Command; +use walkdir::WalkDir; -#[derive(Args, Debug)] -pub struct DoctorArgs {} +#[derive(Args, Debug, Clone)] +pub struct DoctorArgs { + /// Project or workspace path to inspect. + #[arg(long, default_value = ".", value_name = "PATH")] + pub path: PathBuf, + /// Exit with a non-zero code when warnings are found. + #[arg(long, default_value_t = false)] + pub strict: bool, +} static CHECK: Emoji<'_, '_> = Emoji("✅ ", "+ "); static WARN: Emoji<'_, '_> = Emoji("⚠️ ", "! "); static ERROR: Emoji<'_, '_> = Emoji("❌ ", "x "); -pub async fn doctor(_args: DoctorArgs) -> Result<()> { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DoctorStatus { + Pass, + Warn, + Fail, +} + +#[derive(Debug, Clone)] +struct DoctorCheck { + status: DoctorStatus, + name: &'static str, + detail: String, +} + +impl DoctorCheck { + fn pass(name: &'static str, detail: impl Into) -> Self { + Self { + status: DoctorStatus::Pass, + name, + detail: detail.into(), + } + } + + fn warn(name: &'static str, detail: impl Into) -> Self { + Self { + status: DoctorStatus::Warn, + name, + detail: detail.into(), + } + } + + fn fail(name: &'static str, detail: impl Into) -> Self { + Self { + status: DoctorStatus::Fail, + name, + detail: detail.into(), + } + } +} + +#[derive(Debug, Default, Clone)] +struct WorkspaceSignals { + production_defaults: bool, + env_production: bool, + health_endpoints: bool, + health_checks: bool, + request_id: bool, + tracing: bool, + shutdown: bool, + shutdown_hooks: bool, + structured_logging: bool, + otel: bool, + rate_limit: bool, + security_headers: bool, + timeout: bool, + cors: bool, + body_limit: bool, +} + +pub async fn doctor(args: DoctorArgs) -> Result<()> { println!("{}", style("Checking environment health...").bold()); println!(); - check_tool("rustc", &["--version"], "Rust compiler").await; - check_tool("cargo", &["--version"], "Cargo package manager").await; - check_tool( + let mut checks = Vec::new(); + + println!("{}", style("Toolchain").bold()); + checks.push(check_tool("rustc", &["--version"], "Rust compiler", true).await); + checks.push(check_tool( + "cargo", + &["--version"], + "Cargo package manager", + true, + ) + .await); + checks.push(check_tool( "cargo", &["watch", "--version"], "cargo-watch (for hot reload)", + false, + ) + .await); + checks.push(check_tool( + "docker", + &["--version"], + "Docker (for containerization)", + false, + ) + .await); + checks.push(check_tool( + "sqlx", + &["--version"], + "sqlx-cli (for database migrations)", + false, ) - .await; - check_tool("docker", &["--version"], "Docker (for containerization)").await; - check_tool("sqlx", &["--version"], "sqlx-cli (for database migrations)").await; + .await); + + for check in &checks { + print_check(check); + } + + let inspect_path = if args.path.is_absolute() { + args.path.clone() + } else { + std::env::current_dir() + .context("failed to determine current directory")? + .join(&args.path) + }; println!(); - println!("{}", style("Doctor check passed!").green()); + println!("{}", style("Production checklist alignment").bold()); + + if let Some(workspace_root) = find_workspace_root(&inspect_path) { + print_check(&DoctorCheck::pass( + "Workspace root", + format!("found {}", workspace_root.display()), + )); + + let project_checks = build_project_checks(&workspace_root)?; + for check in &project_checks { + print_check(check); + } + checks.extend(project_checks); + } else { + checks.push(DoctorCheck::warn( + "Workspace root", + format!( + "No Cargo.toml found above {} — skipped project-level checklist checks", + inspect_path.display() + ), + )); + print_check(checks.last().unwrap()); + } + + println!(); + + let failures = checks + .iter() + .filter(|check| check.status == DoctorStatus::Fail) + .count(); + let warnings = checks + .iter() + .filter(|check| check.status == DoctorStatus::Warn) + .count(); + + if failures == 0 && warnings == 0 { + println!("{}", style("Doctor check passed cleanly.").green()); + return Ok(()); + } + + if failures > 0 { + println!( + "{}", + style(format!( + "Doctor found {} failure(s) and {} warning(s).", + failures, warnings + )) + .red() + ); + bail!("doctor found {failures} failure(s)"); + } + + if args.strict { + println!( + "{}", + style(format!( + "Doctor found {} warning(s) and strict mode is enabled.", + warnings + )) + .yellow() + ); + bail!("doctor found {warnings} warning(s) in strict mode"); + } + + println!( + "{}", + style(format!( + "Doctor completed with {} warning(s). Use --strict to fail on warnings.", + warnings + )) + .yellow() + ); Ok(()) } -async fn check_tool(cmd: &str, args: &[&str], name: &str) { +async fn check_tool(cmd: &str, args: &[&str], name: &'static str, required: bool) -> DoctorCheck { let output = Command::new(cmd).args(args).output().await; match output { @@ -44,25 +218,330 @@ async fn check_tool(cmd: &str, args: &[&str], name: &str) { .unwrap_or("") .trim() .to_string(); - println!("{} {} {}", CHECK, style(name).bold(), style(version).dim()); + DoctorCheck::pass(name, version) } Ok(_) => { - println!( - "{} {} {}", - WARN, - style(name).bold(), - style("installed but returned error").yellow() - ); + if required { + DoctorCheck::fail(name, "installed but returned error") + } else { + DoctorCheck::warn(name, "installed but returned error") + } } Err(_) => { let msg = if cmd == "cargo" && args[0] == "watch" { "(install with: cargo install cargo-watch)" } else if cmd == "sqlx" { "(install with: cargo install sqlx-cli)" + } else if cmd == "docker" { + "(install Docker Desktop or Docker Engine)" } else { "(not found)" }; - println!("{} {} {}", ERROR, style(name).bold(), style(msg).dim()); + + if required { + DoctorCheck::fail(name, msg) + } else { + DoctorCheck::warn(name, msg) + } + } + } +} + +fn print_check(check: &DoctorCheck) { + let icon = match check.status { + DoctorStatus::Pass => CHECK, + DoctorStatus::Warn => WARN, + DoctorStatus::Fail => ERROR, + }; + + let detail = match check.status { + DoctorStatus::Pass => style(&check.detail).dim(), + DoctorStatus::Warn => style(&check.detail).yellow(), + DoctorStatus::Fail => style(&check.detail).red(), + }; + + println!("{} {} {}", icon, style(check.name).bold(), detail); +} + +fn build_project_checks(workspace_root: &Path) -> Result> { + let signals = scan_workspace_signals(workspace_root)?; + let mut checks = Vec::new(); + + if workspace_root.join("scripts/check_quality.ps1").exists() { + checks.push(DoctorCheck::pass( + "Quality gate", + "scripts/check_quality.ps1 is available", + )); + } else { + checks.push(DoctorCheck::warn( + "Quality gate", + "scripts/check_quality.ps1 was not found; run your equivalent build/test gate before deploy", + )); + } + + checks.push(if signals.production_defaults { + DoctorCheck::pass( + "Application baseline", + "production_defaults usage detected", + ) + } else { + DoctorCheck::warn( + "Application baseline", + "No production_defaults(...) or production_defaults_with_config(...) call detected", + ) + }); + + checks.push(if signals.env_production { + DoctorCheck::pass( + "Production environment", + "RUSTAPI_ENV=production detected in project files", + ) + } else { + DoctorCheck::warn( + "Production environment", + "RUSTAPI_ENV=production was not detected in scanned config files", + ) + }); + + checks.push(if signals.production_defaults || signals.health_endpoints || signals.health_checks { + DoctorCheck::pass( + "Health and readiness", + "Health endpoint configuration detected", + ) + } else { + DoctorCheck::warn( + "Health and readiness", + "No .health_endpoints(...), .with_health_check(...), or production preset usage detected", + ) + }); + + checks.push(if signals.shutdown || signals.shutdown_hooks { + DoctorCheck::pass( + "Graceful shutdown", + "Shutdown flow or on_shutdown hook detected", + ) + } else { + DoctorCheck::warn( + "Graceful shutdown", + "No run_with_shutdown(...) or .on_shutdown(...) usage detected", + ) + }); + + checks.push(if (signals.production_defaults || signals.request_id) && (signals.production_defaults || signals.tracing) { + DoctorCheck::pass( + "Request IDs and tracing", + "Request ID and tracing signals detected", + ) + } else { + DoctorCheck::warn( + "Request IDs and tracing", + "RequestIdLayer/tracing signals were not clearly detected", + ) + }); + + checks.push(if signals.structured_logging || signals.otel { + DoctorCheck::pass( + "Observability", + "Structured logging or OpenTelemetry configuration detected", + ) + } else { + DoctorCheck::warn( + "Observability", + "No StructuredLoggingLayer/structured_logging(...) or OtelLayer/otel(...) usage detected", + ) + }); + + checks.push(if signals.rate_limit || signals.security_headers || signals.timeout || signals.cors { + DoctorCheck::pass( + "Edge protections", + "Detected timeout, rate limit, CORS, or security header configuration", + ) + } else { + DoctorCheck::warn( + "Edge protections", + "No timeout, rate limit, CORS, or security header configuration was detected", + ) + }); + + checks.push(if signals.body_limit { + DoctorCheck::pass( + "Payload management", + "Body limit configuration detected", + ) + } else { + DoctorCheck::warn( + "Payload management", + "No body limit override detected; validate that the default 1 MB limit matches your traffic", + ) + }); + + Ok(checks) +} + +fn scan_workspace_signals(workspace_root: &Path) -> Result { + let mut signals = WorkspaceSignals::default(); + + for entry in WalkDir::new(workspace_root) + .into_iter() + .filter_entry(|entry| should_scan(entry.path())) + { + let entry = entry?; + if !entry.file_type().is_file() || !is_scannable_file(entry.path()) { + continue; + } + + let contents = match fs::read_to_string(entry.path()) { + Ok(contents) => contents, + Err(_) => continue, + }; + + signals.production_defaults |= contains_any( + &contents, + &[".production_defaults(", ".production_defaults_with_config("], + ); + signals.health_endpoints |= contains_any( + &contents, + &[".health_endpoints(", ".health_endpoint_config(", "HealthEndpointConfig"], + ); + signals.health_checks |= contents.contains(".with_health_check("); + signals.request_id |= contents.contains("RequestIdLayer"); + signals.tracing |= contains_any(&contents, &["TracingLayer", "tracing_subscriber"]); + signals.shutdown |= contents.contains("run_with_shutdown("); + signals.shutdown_hooks |= contents.contains(".on_shutdown("); + signals.structured_logging |= contains_any( + &contents, + &["StructuredLoggingLayer", "structured_logging("], + ); + signals.otel |= contains_any(&contents, &["OtelLayer", "otel("]); + signals.rate_limit |= contains_any(&contents, &["RateLimitLayer", "rate_limit("]); + signals.security_headers |= contains_any( + &contents, + &["SecurityHeadersLayer", "security_headers("], + ); + signals.timeout |= contains_any(&contents, &["TimeoutLayer", "timeout("]); + signals.cors |= contains_any(&contents, &["CorsLayer", "cors("]); + signals.body_limit |= contains_any(&contents, &["BodyLimitLayer", ".body_limit("]); + signals.env_production |= contains_any( + &contents, + &[ + "RUSTAPI_ENV=production", + "RUSTAPI_ENV: production", + "RUSTAPI_ENV = \"production\"", + "RUSTAPI_ENV','production", + "RUSTAPI_ENV\", \"production\"", + ], + ); + } + + Ok(signals) +} + +fn find_workspace_root(start: &Path) -> Option { + let mut current = if start.is_dir() { + start.to_path_buf() + } else { + start.parent()?.to_path_buf() + }; + + loop { + if current.join("Cargo.toml").exists() { + return Some(current); + } + + if !current.pop() { + return None; } } } + +fn contains_any(haystack: &str, patterns: &[&str]) -> bool { + patterns.iter().any(|pattern| haystack.contains(pattern)) +} + +fn should_scan(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return true; + }; + + !matches!(name, ".git" | "target" | "node_modules" | ".next" | "dist" | "build") +} + +fn is_scannable_file(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + + if matches!(name, ".env" | ".env.example" | "Dockerfile") { + return true; + } + + matches!( + path.extension().and_then(|ext| ext.to_str()), + Some("rs" | "toml" | "md" | "yml" | "yaml" | "env") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn find_workspace_root_walks_upwards() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[workspace]\nmembers=[]\n").unwrap(); + let nested = dir.path().join("crates").join("app").join("src"); + fs::create_dir_all(&nested).unwrap(); + + let root = find_workspace_root(&nested).unwrap(); + assert_eq!(root, dir.path()); + } + + #[test] + fn scan_workspace_signals_detects_production_patterns() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname='demo'\nversion='0.1.0'\n").unwrap(); + fs::write(dir.path().join(".env"), "RUSTAPI_ENV=production\n").unwrap(); + + let src_dir = dir.path().join("src"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write( + src_dir.join("main.rs"), + r#" + use rustapi_rs::prelude::*; + + fn app() { + let _app = RustApi::new() + .production_defaults("svc") + .with_health_check(|| async { Ok(()) }) + .on_shutdown(|| async {}) + .layer(StructuredLoggingLayer::default()) + .layer(RateLimitLayer::new(100, std::time::Duration::from_secs(60))) + .layer(BodyLimitLayer::new(2 * 1024 * 1024)); + } + "#, + ) + .unwrap(); + + let signals = scan_workspace_signals(dir.path()).unwrap(); + assert!(signals.production_defaults); + assert!(signals.env_production); + assert!(signals.health_checks); + assert!(signals.shutdown_hooks); + assert!(signals.structured_logging); + assert!(signals.rate_limit); + assert!(signals.body_limit); + } + + #[test] + fn build_project_checks_warns_when_signals_are_missing() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("Cargo.toml"), "[package]\nname='demo'\nversion='0.1.0'\n").unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write(dir.path().join("src").join("main.rs"), "fn main() {}\n").unwrap(); + + let checks = build_project_checks(dir.path()).unwrap(); + assert!(checks.iter().any(|check| check.status == DoctorStatus::Warn)); + assert!(checks.iter().any(|check| check.name == "Application baseline")); + } +} diff --git a/crates/cargo-rustapi/src/commands/mod.rs b/crates/cargo-rustapi/src/commands/mod.rs index 524fc907..76e91cd7 100644 --- a/crates/cargo-rustapi/src/commands/mod.rs +++ b/crates/cargo-rustapi/src/commands/mod.rs @@ -1,6 +1,7 @@ //! CLI commands mod add; +mod bench; mod client; mod deploy; mod docs; @@ -8,10 +9,12 @@ mod doctor; mod generate; mod migrate; mod new; +mod observability; mod run; mod watch; pub use add::{add, AddArgs}; +pub use bench::{bench, BenchArgs}; pub use client::{client, ClientArgs}; pub use deploy::{deploy, DeployArgs}; pub use docs::open_docs; @@ -19,6 +22,7 @@ pub use doctor::{doctor, DoctorArgs}; pub use generate::{generate, GenerateArgs}; pub use migrate::{migrate, MigrateArgs}; pub use new::{new_project, NewArgs}; +pub use observability::{observability, ObservabilityArgs}; pub use run::{run_dev, RunArgs}; pub use watch::{watch, WatchArgs}; diff --git a/crates/cargo-rustapi/src/commands/new.rs b/crates/cargo-rustapi/src/commands/new.rs index a7cb5170..6836928f 100644 --- a/crates/cargo-rustapi/src/commands/new.rs +++ b/crates/cargo-rustapi/src/commands/new.rs @@ -9,7 +9,7 @@ use std::path::Path; use std::time::Duration; use tokio::fs; -use crate::templates::{self, ProjectTemplate}; +use crate::templates::{self, ProjectPreset, ProjectTemplate}; /// Arguments for the `new` command #[derive(Args, Debug)] @@ -21,6 +21,10 @@ pub struct NewArgs { #[arg(short, long, value_enum)] pub template: Option, + /// Opinionated feature preset + #[arg(long, value_enum)] + pub preset: Option, + /// Features to enable #[arg(short, long, value_delimiter = ',')] pub features: Option>, @@ -57,9 +61,37 @@ pub async fn new_project(mut args: NewArgs) -> Result<()> { anyhow::bail!("Directory '{}' already exists", name); } + // Get preset + let preset = if let Some(preset) = args.preset { + Some(preset) + } else if args.yes { + None + } else { + let presets = [ + "none - choose template/features manually", + "prod-api - production-oriented HTTP API defaults", + "ai-api - TOON-ready API defaults", + "realtime-api - WebSocket-ready API defaults", + ]; + let selection = Select::with_theme(&theme) + .with_prompt("Select an optional preset") + .items(&presets) + .default(0) + .interact()?; + + match selection { + 1 => Some(ProjectPreset::ProdApi), + 2 => Some(ProjectPreset::AiApi), + 3 => Some(ProjectPreset::RealtimeApi), + _ => None, + } + }; + // Get template let template = if let Some(template) = args.template { template + } else if let Some(preset) = preset { + preset.default_template() } else if args.yes { ProjectTemplate::Minimal } else { @@ -86,26 +118,44 @@ pub async fn new_project(mut args: NewArgs) -> Result<()> { // Get features let features = if let Some(features) = args.features { - features + merge_unique_features(preset.map(ProjectPreset::recommended_features).unwrap_or_default(), features) } else if args.yes { - vec![] + preset + .map(ProjectPreset::recommended_features) + .unwrap_or_default() } else { let available = [ "extras-jwt", "extras-cors", "extras-rate-limit", "extras-config", + "extras-security-headers", + "extras-structured-logging", + "extras-timeout", "protocol-toon", "protocol-ws", "protocol-view", "protocol-grpc", ]; + let preset_features = preset + .map(ProjectPreset::recommended_features) + .unwrap_or_default(); let defaults = match template { - ProjectTemplate::Full => vec![true, true, true, true, false, false, false, false], - ProjectTemplate::Web => vec![false, false, false, false, false, false, true, false], + ProjectTemplate::Full => vec![ + true, true, true, true, false, false, false, false, false, false, false, + ], + ProjectTemplate::Web => vec![ + false, false, false, false, false, false, false, false, false, true, false, + ], _ => vec![false; available.len()], }; + let defaults = defaults + .into_iter() + .enumerate() + .map(|(index, default)| default || preset_features.iter().any(|feature| feature == available[index])) + .collect::>(); + let selections = dialoguer::MultiSelect::with_theme(&theme) .with_prompt("Select features (space to toggle)") .items(&available) @@ -124,6 +174,15 @@ pub async fn new_project(mut args: NewArgs) -> Result<()> { println!("{}", style("Project configuration:").bold()); println!(" Name: {}", style(&name).cyan()); println!(" Template: {}", style(format!("{:?}", template)).cyan()); + println!( + " Preset: {}", + style( + preset + .map(|preset| format!("{:?}", preset)) + .unwrap_or_else(|| "none".to_string()) + ) + .cyan() + ); println!( " Features: {}", style(if features.is_empty() { @@ -191,6 +250,16 @@ pub async fn new_project(mut args: NewArgs) -> Result<()> { Ok(()) } +fn merge_unique_features(mut base: Vec, extras: Vec) -> Vec { + for feature in extras { + if !base.contains(&feature) { + base.push(feature); + } + } + + base +} + /// Validate project name fn validate_project_name(name: &str) -> Result<()> { if name.is_empty() { diff --git a/crates/cargo-rustapi/src/commands/observability.rs b/crates/cargo-rustapi/src/commands/observability.rs new file mode 100644 index 00000000..f29e9eeb --- /dev/null +++ b/crates/cargo-rustapi/src/commands/observability.rs @@ -0,0 +1,115 @@ +//! Observability workflow command. + +use anyhow::{bail, Context, Result}; +use clap::Args; +use console::style; +use std::path::{Path, PathBuf}; + +/// Surface observability assets and recommended baseline inputs. +#[derive(Args, Debug, Clone)] +pub struct ObservabilityArgs { + /// Project or workspace path to inspect. + #[arg(long, default_value = ".", value_name = "PATH")] + pub path: PathBuf, + /// Exit with a non-zero code when expected observability assets are missing. + #[arg(long, default_value_t = false)] + pub check: bool, +} + +pub async fn observability(args: ObservabilityArgs) -> Result<()> { + let inspect_path = resolve_path(&args.path)?; + let workspace_root = find_workspace_root(&inspect_path) + .with_context(|| format!("No Cargo.toml found above {}", inspect_path.display()))?; + + let assets = [ + ( + "Production baseline", + workspace_root.join("docs").join("PRODUCTION_BASELINE.md"), + ), + ( + "Observability cookbook", + workspace_root + .join("docs") + .join("cookbook") + .join("src") + .join("recipes") + .join("observability.md"), + ), + ( + "Benchmark workflow", + workspace_root.join("scripts").join("bench.ps1"), + ), + ( + "Quality gate", + workspace_root.join("scripts").join("check_quality.ps1"), + ), + ]; + + println!("{}", style("Observability workflow assets").bold()); + println!(); + + let mut missing = 0usize; + for (label, path) in assets { + if path.exists() { + println!( + "{} {}", + style(format!("• {label}:")).bold(), + style(path.display()).cyan() + ); + } else { + missing += 1; + println!( + "{} {}", + style(format!("• {label}:")).bold(), + style(format!("missing at {}", path.display())).yellow() + ); + } + } + + println!(); + println!("{}", style("Recommended feature block").bold()); + println!(" extras-otel"); + println!(" extras-structured-logging"); + println!(" extras-insight"); + println!(" extras-timeout"); + println!(" extras-cors"); + println!(); + println!("{}", style("Suggested CLI flow").bold()); + println!(" 1. cargo rustapi doctor --strict"); + println!(" 2. cargo rustapi observability --check"); + println!(" 3. cargo rustapi bench"); + + if missing > 0 && args.check { + bail!("observability workflow is missing {missing} required asset(s)"); + } + + Ok(()) +} + +fn resolve_path(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(std::env::current_dir() + .context("failed to determine current directory")? + .join(path)) + } +} + +fn find_workspace_root(start: &Path) -> Option { + let mut current = if start.is_dir() { + start.to_path_buf() + } else { + start.parent()?.to_path_buf() + }; + + loop { + if current.join("Cargo.toml").exists() { + return Some(current); + } + + if !current.pop() { + return None; + } + } +} diff --git a/crates/cargo-rustapi/src/templates/mod.rs b/crates/cargo-rustapi/src/templates/mod.rs index 34ecfc12..da12aa47 100644 --- a/crates/cargo-rustapi/src/templates/mod.rs +++ b/crates/cargo-rustapi/src/templates/mod.rs @@ -21,6 +21,56 @@ pub enum ProjectTemplate { Full, } +/// Opinionated feature presets layered on top of project templates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum ProjectPreset { + /// Production-oriented HTTP API defaults. + #[value(name = "prod-api")] + ProdApi, + /// AI-friendly API defaults with TOON support. + #[value(name = "ai-api")] + AiApi, + /// Realtime API defaults with WebSocket support. + #[value(name = "realtime-api")] + RealtimeApi, +} + +impl ProjectPreset { + /// Default base template for this preset. + pub fn default_template(self) -> ProjectTemplate { + ProjectTemplate::Api + } + + /// Recommended features that should be enabled for this preset. + pub fn recommended_features(self) -> Vec { + match self { + ProjectPreset::ProdApi => vec![ + "extras-config", + "extras-cors", + "extras-rate-limit", + "extras-security-headers", + "extras-structured-logging", + "extras-timeout", + ], + ProjectPreset::AiApi => vec![ + "extras-config", + "extras-structured-logging", + "extras-timeout", + "protocol-toon", + ], + ProjectPreset::RealtimeApi => vec![ + "extras-cors", + "extras-structured-logging", + "extras-timeout", + "protocol-ws", + ], + } + .into_iter() + .map(str::to_string) + .collect() + } +} + /// Generate a project from a template pub async fn generate_project( name: &str, diff --git a/crates/cargo-rustapi/tests/cli_tests.rs b/crates/cargo-rustapi/tests/cli_tests.rs index 28f13bee..2b8d8fc7 100644 --- a/crates/cargo-rustapi/tests/cli_tests.rs +++ b/crates/cargo-rustapi/tests/cli_tests.rs @@ -104,6 +104,86 @@ mod new_command { ); } + #[test] + fn test_new_with_prod_api_preset() { + let dir = tempdir().expect("Failed to create temp dir"); + let project_name = "test-prod-preset-project"; + let project_path = dir.path().join(project_name); + + cargo_rustapi() + .current_dir(dir.path()) + .args([ + "new", + project_name, + "--preset", + "prod-api", + "--yes", + ]) + .assert() + .success(); + + let cargo_content = + fs::read_to_string(project_path.join("Cargo.toml")).expect("Failed to read Cargo.toml"); + assert!(cargo_content.contains("extras-config")); + assert!(cargo_content.contains("extras-cors")); + assert!(cargo_content.contains("extras-rate-limit")); + assert!(cargo_content.contains("extras-security-headers")); + assert!(cargo_content.contains("extras-structured-logging")); + assert!(cargo_content.contains("extras-timeout")); + } + + #[test] + fn test_new_with_ai_api_preset() { + let dir = tempdir().expect("Failed to create temp dir"); + let project_name = "test-ai-preset-project"; + let project_path = dir.path().join(project_name); + + cargo_rustapi() + .current_dir(dir.path()) + .args([ + "new", + project_name, + "--preset", + "ai-api", + "--yes", + ]) + .assert() + .success(); + + let cargo_content = + fs::read_to_string(project_path.join("Cargo.toml")).expect("Failed to read Cargo.toml"); + assert!(cargo_content.contains("protocol-toon")); + assert!(cargo_content.contains("extras-config")); + assert!(cargo_content.contains("extras-timeout")); + assert!(cargo_content.contains("extras-structured-logging")); + } + + #[test] + fn test_new_with_realtime_api_preset() { + let dir = tempdir().expect("Failed to create temp dir"); + let project_name = "test-realtime-preset-project"; + let project_path = dir.path().join(project_name); + + cargo_rustapi() + .current_dir(dir.path()) + .args([ + "new", + project_name, + "--preset", + "realtime-api", + "--yes", + ]) + .assert() + .success(); + + let cargo_content = + fs::read_to_string(project_path.join("Cargo.toml")).expect("Failed to read Cargo.toml"); + assert!(cargo_content.contains("protocol-ws")); + assert!(cargo_content.contains("extras-cors")); + assert!(cargo_content.contains("extras-timeout")); + assert!(cargo_content.contains("extras-structured-logging")); + } + #[test] fn test_new_existing_directory_fails() { let dir = tempdir().expect("Failed to create temp dir"); @@ -167,6 +247,54 @@ mod doctor_command { } } +mod bench_command { + use super::*; + + #[test] + fn test_bench_help() { + cargo_rustapi() + .args(["bench", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("benchmark workflow")); + } +} + +mod observability_command { + use super::*; + + #[test] + fn test_observability_help() { + cargo_rustapi() + .args(["observability", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("observability")); + } + + #[test] + fn test_observability_runs_against_repo() { + cargo_rustapi() + .args(["observability", "--path", "."]) + .assert() + .success() + .stdout(predicate::str::contains("Observability workflow assets")); + } +} + +mod replay_command { + use super::*; + + #[test] + fn test_replay_help() { + cargo_rustapi() + .args(["replay", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Replay debugging commands")); + } +} + mod generate_command { use super::*; diff --git a/crates/rustapi-core/src/multipart.rs b/crates/rustapi-core/src/multipart.rs index 945fbd0b..2dba6313 100644 --- a/crates/rustapi-core/src/multipart.rs +++ b/crates/rustapi-core/src/multipart.rs @@ -112,6 +112,8 @@ impl StreamingMultipart { } /// Get the next field from the multipart stream. + /// + /// Consume or drop the previously returned field before calling this again. pub async fn next_field(&mut self) -> Result>> { let field = self.inner.next_field().await.map_err(map_multer_error)?; let Some(field) = field else { @@ -870,11 +872,13 @@ mod tests { let mut title = multipart.next_field().await.unwrap().unwrap(); assert_eq!(title.name(), Some("title")); assert_eq!(title.text().await.unwrap(), "hello"); + drop(title); let mut file = multipart.next_field().await.unwrap().unwrap(); assert_eq!(file.file_name(), Some("demo.txt")); assert_eq!(file.content_type(), Some("text/plain")); assert_eq!(file.bytes().await.unwrap(), Bytes::from("streamed-content")); + drop(file); assert!(multipart.next_field().await.unwrap().is_none()); assert_eq!(multipart.field_count(), 2); diff --git a/crates/rustapi-extras/src/lib.rs b/crates/rustapi-extras/src/lib.rs index 75d302ad..8f282e43 100644 --- a/crates/rustapi-extras/src/lib.rs +++ b/crates/rustapi-extras/src/lib.rs @@ -126,7 +126,7 @@ pub use jwt::{create_token, AuthUser, JwtError, JwtLayer, JwtValidation, Validat pub use cors::{AllowedOrigins, CorsLayer}; #[cfg(feature = "rate-limit")] -pub use rate_limit::RateLimitLayer; +pub use rate_limit::{RateLimitLayer, RateLimitStrategy}; #[cfg(feature = "config")] pub use config::{ diff --git a/crates/rustapi-extras/src/rate_limit/mod.rs b/crates/rustapi-extras/src/rate_limit/mod.rs index 6bd644d9..1914bf0e 100644 --- a/crates/rustapi-extras/src/rate_limit/mod.rs +++ b/crates/rustapi-extras/src/rate_limit/mod.rs @@ -19,6 +19,7 @@ use http::StatusCode; use http_body_util::Full; use rustapi_core::middleware::{BoxedNext, MiddlewareLayer}; use rustapi_core::{Request, Response, ResponseBody}; +use std::collections::VecDeque; use std::future::Future; use std::net::IpAddr; use std::pin::Pin; @@ -27,9 +28,36 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; /// Internal entry for tracking rate limit state per client. #[derive(Debug, Clone)] -struct RateLimitEntry { - count: u32, - window_start: Instant, +enum RateLimitEntry { + FixedWindow { + count: u32, + window_start: Instant, + }, + SlidingWindow { + requests: VecDeque, + }, + TokenBucket { + tokens: f64, + last_refill: Instant, + }, +} + +#[derive(Debug, Clone, Copy)] +struct RateLimitDecision { + is_allowed: bool, + remaining: u32, + retry_after: Duration, +} + +/// Supported rate limiting strategies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RateLimitStrategy { + /// Traditional fixed window counter. + FixedWindow, + /// Rolling window that expires each request individually. + SlidingWindow, + /// Burst-friendly token bucket that refills over time. + TokenBucket, } /// Internal store for tracking request counts per IP. @@ -52,68 +80,200 @@ impl RateLimitStore { ip: IpAddr, max_requests: u32, window: Duration, + strategy: RateLimitStrategy, ) -> (bool, u32, u32, u64) { let now = Instant::now(); - let mut entry = self.entries.entry(ip).or_insert_with(|| RateLimitEntry { - count: 0, - window_start: now, - }); + let mut entry = self + .entries + .entry(ip) + .or_insert_with(|| RateLimitStore::new_entry(strategy, max_requests, now)); + + let decision = match (&mut *entry, strategy) { + (RateLimitEntry::FixedWindow { count, window_start }, RateLimitStrategy::FixedWindow) => { + if now.duration_since(*window_start) >= window { + *count = 0; + *window_start = now; + } - // Check if window has expired and reset if needed - if now.duration_since(entry.window_start) >= window { - entry.count = 0; - entry.window_start = now; - } + *count += 1; + RateLimitDecision { + is_allowed: *count <= max_requests, + remaining: max_requests.saturating_sub(*count), + retry_after: window.saturating_sub(now.duration_since(*window_start)), + } + } + (RateLimitEntry::SlidingWindow { requests }, RateLimitStrategy::SlidingWindow) => { + while let Some(oldest) = requests.front() { + if now.duration_since(*oldest) >= window { + requests.pop_front(); + } else { + break; + } + } + + let is_allowed = requests.len() < max_requests as usize; + if is_allowed { + requests.push_back(now); + } - // Increment count - entry.count += 1; - let current_count = entry.count; + let retry_after = requests + .front() + .map(|oldest| window.saturating_sub(now.duration_since(*oldest))) + .unwrap_or(Duration::ZERO); - // Calculate remaining - let remaining = max_requests.saturating_sub(current_count); - let is_allowed = current_count <= max_requests; + RateLimitDecision { + is_allowed, + remaining: max_requests.saturating_sub(requests.len() as u32), + retry_after, + } + } + (RateLimitEntry::TokenBucket { tokens, last_refill }, RateLimitStrategy::TokenBucket) => { + let refill_rate = max_requests as f64 / window.as_secs_f64().max(f64::EPSILON); + let elapsed = now.duration_since(*last_refill).as_secs_f64(); + *tokens = (*tokens + elapsed * refill_rate).min(max_requests as f64); + *last_refill = now; + + let is_allowed = *tokens >= 1.0; + if is_allowed { + *tokens -= 1.0; + } - // Calculate actual reset timestamp based on window start - let elapsed = now.duration_since(entry.window_start); - let time_until_reset = window.saturating_sub(elapsed); - let actual_reset = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - + time_until_reset.as_secs(); + let remaining = tokens.floor().max(0.0).min(max_requests as f64) as u32; + let retry_after = next_token_after(*tokens, max_requests, refill_rate); - (is_allowed, current_count, remaining, actual_reset) + RateLimitDecision { + is_allowed, + remaining, + retry_after, + } + } + (entry, _) => { + *entry = RateLimitStore::new_entry(strategy, max_requests, now); + let _ = entry; + return self.check_and_update(ip, max_requests, window, strategy); + } + }; + + let reset = unix_timestamp_after(decision.retry_after); + ( + decision.is_allowed, + max_requests.saturating_sub(decision.remaining), + decision.remaining, + reset, + ) } /// Get current rate limit info for a client without incrementing. #[allow(dead_code)] - fn get_info(&self, ip: IpAddr, max_requests: u32, window: Duration) -> Option { + fn get_info( + &self, + ip: IpAddr, + max_requests: u32, + window: Duration, + strategy: RateLimitStrategy, + ) -> Option { let now = Instant::now(); - self.entries.get(&ip).map(|entry| { - // Check if window has expired - let (count, window_start) = if now.duration_since(entry.window_start) >= window { - (0, now) - } else { - (entry.count, entry.window_start) - }; - - let remaining = max_requests.saturating_sub(count); - let elapsed = now.duration_since(window_start); - let time_until_reset = window.saturating_sub(elapsed); - let reset = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - + time_until_reset.as_secs(); - - RateLimitInfo { - limit: max_requests, - remaining, - reset, + self.entries.get(&ip).map(|entry| match (&*entry, strategy) { + (RateLimitEntry::FixedWindow { count, window_start }, RateLimitStrategy::FixedWindow) => { + let current_count = if now.duration_since(*window_start) >= window { + 0 + } else { + *count + }; + + RateLimitInfo { + limit: max_requests, + remaining: max_requests.saturating_sub(current_count), + reset: unix_timestamp_after(window.saturating_sub(now.duration_since(*window_start))), + } + } + (RateLimitEntry::SlidingWindow { requests }, RateLimitStrategy::SlidingWindow) => { + let active = requests + .iter() + .copied() + .filter(|timestamp| now.duration_since(*timestamp) < window) + .collect::>(); + let retry_after = active + .first() + .map(|oldest| window.saturating_sub(now.duration_since(*oldest))) + .unwrap_or(Duration::ZERO); + + RateLimitInfo { + limit: max_requests, + remaining: max_requests.saturating_sub(active.len() as u32), + reset: unix_timestamp_after(retry_after), + } } + (RateLimitEntry::TokenBucket { tokens, last_refill }, RateLimitStrategy::TokenBucket) => { + let refill_rate = max_requests as f64 / window.as_secs_f64().max(f64::EPSILON); + let elapsed = now.duration_since(*last_refill).as_secs_f64(); + let available = (*tokens + elapsed * refill_rate).min(max_requests as f64); + let retry_after = next_token_after(available, max_requests, refill_rate); + + RateLimitInfo { + limit: max_requests, + remaining: available.floor().max(0.0).min(max_requests as f64) as u32, + reset: unix_timestamp_after(retry_after), + } + } + _ => RateLimitInfo { + limit: max_requests, + remaining: max_requests, + reset: unix_timestamp_after(Duration::ZERO), + }, }) } + + fn new_entry(strategy: RateLimitStrategy, max_requests: u32, now: Instant) -> RateLimitEntry { + match strategy { + RateLimitStrategy::FixedWindow => RateLimitEntry::FixedWindow { + count: 0, + window_start: now, + }, + RateLimitStrategy::SlidingWindow => RateLimitEntry::SlidingWindow { + requests: VecDeque::new(), + }, + RateLimitStrategy::TokenBucket => RateLimitEntry::TokenBucket { + tokens: max_requests as f64, + last_refill: now, + }, + } + } +} + +fn next_token_after(tokens: f64, max_requests: u32, refill_rate: f64) -> Duration { + if refill_rate <= f64::EPSILON || tokens >= max_requests as f64 { + return Duration::ZERO; + } + + let fractional = tokens.fract(); + let needed = if fractional <= f64::EPSILON { + 1.0 + } else { + 1.0 - fractional + }; + + Duration::from_secs_f64((needed / refill_rate).max(0.0)) +} + +fn unix_timestamp_after(duration: Duration) -> u64 { + unix_now_secs() + duration_to_header_secs(duration) +} + +fn unix_now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn duration_to_header_secs(duration: Duration) -> u64 { + if duration.is_zero() { + 0 + } else { + duration.as_secs().max(1) + } } /// Rate limiting middleware layer. @@ -135,6 +295,7 @@ impl RateLimitStore { pub struct RateLimitLayer { requests: u32, window: Duration, + strategy: RateLimitStrategy, store: Arc, } @@ -159,6 +320,27 @@ impl RateLimitLayer { Self { requests, window, + strategy: RateLimitStrategy::FixedWindow, + store: Arc::new(RateLimitStore::new()), + } + } + + /// Create a rate limiter that expires requests individually using a rolling window. + pub fn sliding_window(requests: u32, window: Duration) -> Self { + Self { + requests, + window, + strategy: RateLimitStrategy::SlidingWindow, + store: Arc::new(RateLimitStore::new()), + } + } + + /// Create a token bucket limiter that allows bursts and refills over the given window. + pub fn token_bucket(capacity: u32, refill_window: Duration) -> Self { + Self { + requests: capacity, + window: refill_window, + strategy: RateLimitStrategy::TokenBucket, store: Arc::new(RateLimitStore::new()), } } @@ -173,6 +355,11 @@ impl RateLimitLayer { self.window } + /// Get the configured limiting strategy. + pub fn strategy(&self) -> RateLimitStrategy { + self.strategy + } + /// Get the internal store (for testing purposes). #[cfg(test)] #[allow(dead_code)] @@ -221,19 +408,17 @@ impl MiddlewareLayer for RateLimitLayer { let store = self.store.clone(); let max_requests = self.requests; let window = self.window; + let strategy = self.strategy; Box::pin(async move { let client_ip = RateLimitLayer::extract_client_ip(&req); let (is_allowed, _count, remaining, reset) = - store.check_and_update(client_ip, max_requests, window); + store.check_and_update(client_ip, max_requests, window, strategy); if !is_allowed { // Calculate Retry-After in seconds - let now_secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + let now_secs = unix_now_secs(); let retry_after = reset.saturating_sub(now_secs); // Return 429 Too Many Requests @@ -597,6 +782,23 @@ mod tests { let layer = RateLimitLayer::new(100, Duration::from_secs(60)); assert_eq!(layer.requests(), 100); assert_eq!(layer.window(), Duration::from_secs(60)); + assert_eq!(layer.strategy(), RateLimitStrategy::FixedWindow); + } + + #[test] + fn test_sliding_window_layer_creation() { + let layer = RateLimitLayer::sliding_window(100, Duration::from_secs(60)); + assert_eq!(layer.requests(), 100); + assert_eq!(layer.window(), Duration::from_secs(60)); + assert_eq!(layer.strategy(), RateLimitStrategy::SlidingWindow); + } + + #[test] + fn test_token_bucket_layer_creation() { + let layer = RateLimitLayer::token_bucket(5, Duration::from_secs(10)); + assert_eq!(layer.requests(), 5); + assert_eq!(layer.window(), Duration::from_secs(10)); + assert_eq!(layer.strategy(), RateLimitStrategy::TokenBucket); } #[test] @@ -681,4 +883,57 @@ mod tests { assert!(body_json["error"]["retry_after"].is_number()); }); } + + #[test] + fn test_sliding_window_keeps_recent_requests_in_window() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let layer = RateLimitLayer::sliding_window(2, Duration::from_millis(40)); + let mut stack = LayerStack::new(); + stack.push(Box::new(layer)); + + let request = create_test_request(Some("10.0.0.2")); + let response = stack.execute(request, create_success_handler()).await; + assert_eq!(response.status(), StatusCode::OK); + + tokio::time::sleep(Duration::from_millis(35)).await; + + let request = create_test_request(Some("10.0.0.2")); + let response = stack.execute(request, create_success_handler()).await; + assert_eq!(response.status(), StatusCode::OK); + + tokio::time::sleep(Duration::from_millis(10)).await; + + let request = create_test_request(Some("10.0.0.2")); + let response = stack.execute(request, create_success_handler()).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers().get("X-RateLimit-Remaining").unwrap(), "0"); + }); + } + + #[test] + fn test_token_bucket_refills_after_wait() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let layer = RateLimitLayer::token_bucket(2, Duration::from_millis(40)); + let mut stack = LayerStack::new(); + stack.push(Box::new(layer)); + + for _ in 0..2 { + let request = create_test_request(Some("10.0.0.3")); + let response = stack.execute(request, create_success_handler()).await; + assert_eq!(response.status(), StatusCode::OK); + } + + let request = create_test_request(Some("10.0.0.3")); + let response = stack.execute(request, create_success_handler()).await; + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + + tokio::time::sleep(Duration::from_millis(25)).await; + + let request = create_test_request(Some("10.0.0.3")); + let response = stack.execute(request, create_success_handler()).await; + assert_eq!(response.status(), StatusCode::OK); + }); + } } diff --git a/crates/rustapi-rs/src/lib.rs b/crates/rustapi-rs/src/lib.rs index d052027c..cb4dd719 100644 --- a/crates/rustapi-rs/src/lib.rs +++ b/crates/rustapi-rs/src/lib.rs @@ -103,7 +103,7 @@ pub mod extras { #[cfg(any(feature = "extras-rate-limit", feature = "rate-limit"))] pub mod rate_limit { pub use rustapi_extras::rate_limit; - pub use rustapi_extras::RateLimitLayer; + pub use rustapi_extras::{RateLimitLayer, RateLimitStrategy}; } #[cfg(any(feature = "extras-config", feature = "config"))] @@ -187,7 +187,15 @@ pub mod extras { #[cfg(any(feature = "extras-replay", feature = "replay"))] pub mod replay { + pub use rustapi_core::replay::{ + RecordedRequest, RecordedResponse, ReplayConfig, ReplayEntry, ReplayId, ReplayMeta, + ReplayQuery, ReplayStore, ReplayStoreError, ReplayStoreResult, + }; pub use rustapi_extras::replay; + pub use rustapi_extras::replay::{ + FsReplayStore, FsReplayStoreConfig, InMemoryReplayStore, ReplayAdminAuth, + ReplayClient, ReplayClientError, ReplayLayer, RetentionJob, + }; } #[cfg(any(feature = "extras-oauth2-client", feature = "oauth2-client"))] @@ -261,7 +269,7 @@ pub use rustapi_extras::{AllowedOrigins, CorsLayer}; #[cfg(any(feature = "extras-rate-limit", feature = "rate-limit"))] pub use rustapi_extras::rate_limit; #[cfg(any(feature = "extras-rate-limit", feature = "rate-limit"))] -pub use rustapi_extras::RateLimitLayer; +pub use rustapi_extras::{RateLimitLayer, RateLimitStrategy}; #[cfg(any(feature = "extras-config", feature = "config"))] pub use rustapi_extras::config; @@ -369,7 +377,7 @@ pub mod prelude { pub use crate::{AllowedOrigins, CorsLayer}; #[cfg(any(feature = "extras-rate-limit", feature = "rate-limit"))] - pub use crate::RateLimitLayer; + pub use crate::{RateLimitLayer, RateLimitStrategy}; #[cfg(any(feature = "extras-config", feature = "config"))] pub use crate::{ diff --git a/docs/README.md b/docs/README.md index 3cc50abb..7ada5050 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,9 +10,12 @@ Welcome to the RustAPI documentation! | [Features](FEATURES.md) | Complete feature reference | | [Philosophy](PHILOSOPHY.md) | Design principles and decisions | | [Architecture](ARCHITECTURE.md) | Internal structure deep dive | +| [GraphQL Adapter Plan](GRAPHQL_ADAPTER_PLAN.md) | Planned GraphQL integration shape and facade design | +| [Adaptive Execution Debug Plan](ADAPTIVE_EXECUTION_DEBUG_PLAN.md) | Proposed profiling/debug UX for making execution tiers visible in traces, logs, metrics, and headers | | [Performance Benchmarks](PERFORMANCE_BENCHMARKS.md) | Authoritative source for benchmark methodology and published claims | | [Recommended Production Baseline](PRODUCTION_BASELINE.md) | Opinionated starting point for production services | | [Production Checklist](PRODUCTION_CHECKLIST.md) | Rollout-ready operational checklist | +| [Cookbook: Replay Workflow](cookbook/src/recipes/replay.md) | Official capture → inspect → replay → diff flow for time-travel debugging | ## What is RustAPI? @@ -75,6 +78,7 @@ Current examples in this repository: - [Cookbook: Graceful Shutdown](cookbook/src/recipes/graceful_shutdown.md) - [Cookbook: Deployment](cookbook/src/recipes/deployment.md) - [Cookbook: Observability](cookbook/src/recipes/observability.md) +- [Cookbook: Replay Workflow](cookbook/src/recipes/replay.md) ## License diff --git a/docs/cookbook/src/recipes/observability.md b/docs/cookbook/src/recipes/observability.md index b506ea20..7e3c7920 100644 --- a/docs/cookbook/src/recipes/observability.md +++ b/docs/cookbook/src/recipes/observability.md @@ -174,4 +174,5 @@ If you need probe telemetry for a specific incident, re-enable it deliberately r - [Recommended Production Baseline](../../../PRODUCTION_BASELINE.md) - [Production Checklist](../../../PRODUCTION_CHECKLIST.md) +- [Adaptive Execution Debug Plan](../../../ADAPTIVE_EXECUTION_DEBUG_PLAN.md) - [Graceful Shutdown](graceful_shutdown.md) \ No newline at end of file diff --git a/docs/cookbook/src/recipes/replay.md b/docs/cookbook/src/recipes/replay.md index f44c1fe5..e16f26c9 100644 --- a/docs/cookbook/src/recipes/replay.md +++ b/docs/cookbook/src/recipes/replay.md @@ -1,245 +1,286 @@ -# Replay: Time-Travel Debugging +# Replay workflow: time-travel debugging -Record HTTP request/response pairs and replay them against different environments for debugging and regression testing. +Record HTTP request/response pairs in a controlled environment, inspect a captured request, replay it against another target, and diff the result before promoting a fix. -> **Security Notice**: The replay system is designed for **development and staging environments only**. See [Security](#security) for details. +> **Security notice** +> Replay is intended for **development, staging, canary, and incident-response environments**. Do not expose the admin endpoints publicly on the open internet. -## Quick Start +## Ne zaman kullanılır? -Add the `replay` feature to your `Cargo.toml`: +Replay en çok şu durumlarda işe yarar: + +- staging ile local arasında davranış farkı varsa +- bir regresyonu gerçek trafik örneğiyle yeniden üretmek istiyorsanız +- yeni bir sürümü canary ortamına almadan önce kritik istekleri tekrar koşturmak istiyorsanız +- “bu istek neden dün çalışıyordu da bugün bozuldu?” sorusuna zaman makinesi tadında cevap arıyorsanız + +## Ön koşullar + +Uygulamada canonical replay feature'ını açın: ```toml [dependencies] -rustapi-rs = { version = "0.1.335", features = ["replay"] } +rustapi-rs = { version = "0.1.335", features = ["extras-replay"] } +``` + +CLI tarafında `cargo-rustapi` yeterlidir; replay komutları varsayılan kurulumun parçasıdır: + +```bash +cargo install cargo-rustapi ``` -Add the `ReplayLayer` middleware to your application: +## 1) Replay kaydını etkinleştir + +En küçük pratik kurulum için in-memory store ile başlayın: ```rust,ignore +use rustapi_rs::extras::replay::{InMemoryReplayStore, ReplayConfig, ReplayLayer}; use rustapi_rs::prelude::*; -use rustapi_rs::replay::{ReplayLayer, InMemoryReplayStore}; -use rustapi_core::replay::ReplayConfig; -#[tokio::main] -async fn main() -> Result<(), Box> { +#[rustapi_rs::get("/api/users")] +async fn list_users() -> Json> { + Json(vec!["Alice", "Bob"]) +} + +#[rustapi_rs::main] +async fn main() -> std::result::Result<(), Box> { let replay = ReplayLayer::new( ReplayConfig::new() .enabled(true) - .admin_token("my-secret-token") - .ttl_secs(3600) - ); - - RustApi::new() + .admin_token("local-replay-token") + .ttl_secs(900) + .skip_path("/health") + .skip_path("/ready") + .skip_path("/live"), + ) + .with_store(InMemoryReplayStore::new(200)); + + RustApi::auto() .layer(replay) - .route("/api/users", get(list_users)) .run("127.0.0.1:8080") .await } - -async fn list_users() -> Json> { - Json(vec!["Alice".into(), "Bob".into()]) -} ``` -## How It Works +Bu kurulum şunları yapar: -1. **Record**: The `ReplayLayer` middleware captures HTTP request/response pairs as they flow through your application -2. **List**: Query recorded entries via the admin API or CLI -3. **Replay**: Re-send a recorded request against any target URL -4. **Diff**: Compare the replayed response against the original to detect regressions +- replay kaydını açık hale getirir +- admin endpoint'lerini bearer token ile korur +- probe endpoint'lerini kayıttan çıkarır +- girdileri 15 dakika saklar +- bellekte en fazla 200 kayıt tutar -## Admin API +## 2) Hedef trafiği üret -All admin endpoints require a bearer token in the `Authorization` header: +Artık uygulamaya normal şekilde istek atın. Replay middleware uygulama kodunuzu değiştirmeden request/response çiftlerini yakalar. -``` -Authorization: Bearer +Kayıt akışı şöyledir: + +1. istek geçer +2. request metadata ve uygun body alanları saklanır +3. response durumu, header'ları ve yakalanabilir body içeriği saklanır +4. kayıt admin API ve CLI üzerinden erişilebilir hale gelir + +## 3) Kayıtları listele ve doğru girdiyi bul + +İlk bakış için CLI en rahat yol: + +```bash +# Son replay girdilerini listele +cargo rustapi replay list -s http://localhost:8080 -t local-replay-token + +# Sadece belirli bir endpoint'i filtrele +cargo rustapi replay list -s http://localhost:8080 -t local-replay-token --method GET --path /api/users --limit 20 ``` -| Method | Path | Description | -|--------|------|-------------| -| GET | `/__rustapi/replays` | List recorded entries | -| GET | `/__rustapi/replays/{id}` | Show a single entry | -| POST | `/__rustapi/replays/{id}/run?target=URL` | Replay against target | -| POST | `/__rustapi/replays/{id}/diff?target=URL` | Replay and compute diff | -| DELETE | `/__rustapi/replays/{id}` | Delete an entry | +Liste çıktısı size şu alanları gösterir: -### Query Parameters for List +- replay kimliği +- HTTP method +- path +- orijinal response status kodu +- toplam süre -- `limit` - Maximum number of entries to return -- `method` - Filter by HTTP method (GET, POST, etc.) -- `path` - Filter by path substring -- `status_min` - Minimum status code filter +## 4) Tek bir girdiyi incele -### Example: cURL +Şüpheli isteği bulduktan sonra tam kaydı açın: ```bash -# List entries -curl -H "Authorization: Bearer my-secret-token" \ - http://localhost:8080/__rustapi/replays?limit=10 +cargo rustapi replay show -s http://localhost:8080 -t local-replay-token +``` -# Show a specific entry -curl -H "Authorization: Bearer my-secret-token" \ - http://localhost:8080/__rustapi/replays/ +Bu komut tipik olarak şunları gösterir: -# Replay against staging -curl -X POST -H "Authorization: Bearer my-secret-token" \ - "http://localhost:8080/__rustapi/replays//run?target=http://staging:8080" +- orijinal request method ve URI +- saklanan header'lar +- yakalanan request body +- orijinal response status/body +- duration, client IP ve request ID gibi meta alanlar -# Replay and diff -curl -X POST -H "Authorization: Bearer my-secret-token" \ - "http://localhost:8080/__rustapi/replays//diff?target=http://staging:8080" +## 5) Aynı isteği başka bir ortama tekrar koştur + +Şimdi aynı isteği local düzeltmeniz, staging ya da canary ortamınız üzerinde çalıştırabilirsiniz: + +```bash +cargo rustapi replay run -s http://localhost:8080 -t local-replay-token -T http://localhost:3000 ``` -## CLI Usage +Pratik kullanım örnekleri: + +- local düzeltmenin gerçekten incident'ı çözüp çözmediğini görmek +- staging ortamının eski üretim davranışıyla uyumunu kontrol etmek +- kritik endpoint'leri deploy öncesi smoke test gibi replay etmek -Install with the `replay` feature: +## 6) Farkları otomatik çıkar + +Asıl sihir burada: replay edilen response ile orijinal response'u karşılaştırın. ```bash -cargo install cargo-rustapi --features replay +cargo rustapi replay diff -s http://localhost:8080 -t local-replay-token -T http://staging:8080 ``` -### Commands +`diff` çıktısı şu alanlarda fark arar: -```bash -# List recorded entries -cargo rustapi replay list -s http://localhost:8080 -t my-secret-token +- status code +- response header'ları +- JSON body alanları + +Bu sayede “200 döndü ama payload değişti” gibi daha sinsi regresyonları da yakalarsınız. + +## Önerilen resmi workflow -# List with filters -cargo rustapi replay list -t my-secret-token --method GET --limit 20 +Bir incident ya da regresyon sırasında önerilen akış şu sıradadır: -# Show entry details -cargo rustapi replay show -t my-secret-token +1. **Kayıt aç**: replay'i staging/canary ortamında kısa TTL ile etkinleştir. +2. **Örneği yakala**: problemi üreten gerçek isteği yeniden geçir. +3. **Listele**: `cargo rustapi replay list` ile doğru girdiyi bul. +4. **İncele**: `cargo rustapi replay show` ile request/response çiftini doğrula. +5. **Düzeltmeyi dene**: girdiyi local veya aday sürüme `run` ile tekrar oynat. +6. **Diff al**: `diff` ile davranışın beklenen şekilde değiştiğini doğrula. +7. **Kapat**: incident sonrası replay kaydını kapat veya TTL'i kısa tut. -# Replay against a target URL -cargo rustapi replay run -T http://staging:8080 -t my-secret-token +Kısacası: **capture → inspect → replay → diff → promote**. -# Replay and diff -cargo rustapi replay diff -T http://staging:8080 -t my-secret-token +## Admin API referansı + +Tüm admin endpoint'leri şu header'ı ister: + +```text +Authorization: Bearer ``` -The `--token` (`-t`) parameter can also be set via the `RUSTAPI_REPLAY_TOKEN` environment variable: +| Method | Path | Açıklama | +|--------|------|----------| +| GET | `/__rustapi/replays` | Kayıtları listele | +| GET | `/__rustapi/replays/{id}` | Tek bir girdiyi göster | +| POST | `/__rustapi/replays/{id}/run?target=URL` | İsteği başka hedefe replay et | +| POST | `/__rustapi/replays/{id}/diff?target=URL` | Replay et ve fark üret | +| DELETE | `/__rustapi/replays/{id}` | Bir girdiyi sil | + +### cURL örnekleri ```bash -export RUSTAPI_REPLAY_TOKEN=my-secret-token -cargo rustapi replay list +curl -H "Authorization: Bearer local-replay-token" \ + "http://localhost:8080/__rustapi/replays?limit=10" + +curl -H "Authorization: Bearer local-replay-token" \ + "http://localhost:8080/__rustapi/replays/" + +curl -X POST -H "Authorization: Bearer local-replay-token" \ + "http://localhost:8080/__rustapi/replays//run?target=http://staging:8080" + +curl -X POST -H "Authorization: Bearer local-replay-token" \ + "http://localhost:8080/__rustapi/replays//diff?target=http://staging:8080" ``` -## Configuration +## Konfigürasyon notları -### ReplayConfig +`ReplayConfig` ile en sık ayarlanan seçenekler: ```rust,ignore -use rustapi_core::replay::ReplayConfig; +use rustapi_rs::extras::replay::ReplayConfig; let config = ReplayConfig::new() - // Enable recording (default: false) .enabled(true) - // Required: admin bearer token - .admin_token("my-secret-token") - // Max entries in store (default: 500) - .store_capacity(1000) - // Entry TTL in seconds (default: 3600 = 1 hour) - .ttl_secs(7200) - // Sampling rate 0.0-1.0 (default: 1.0 = all requests) + .admin_token("local-replay-token") + .store_capacity(1_000) + .ttl_secs(7_200) .sample_rate(0.5) - // Max request body capture size (default: 64KB) .max_request_body(131_072) - // Max response body capture size (default: 256KB) .max_response_body(524_288) - // Only record specific paths - .record_path("/api/users") .record_path("/api/orders") - // Or skip specific paths + .record_path("/api/users") .skip_path("/health") .skip_path("/metrics") - // Add headers to redact .redact_header("x-custom-secret") - // Add body fields to redact .redact_body_field("password") - .redact_body_field("ssn") .redact_body_field("credit_card") - // Custom admin route prefix (default: "/__rustapi/replays") .admin_route_prefix("/__admin/replays"); ``` -### Default Redacted Headers - -The following headers are redacted by default (values replaced with `[REDACTED]`): +Varsayılan olarak şu header'lar `[REDACTED]` olarak saklanır: - `authorization` - `cookie` - `x-api-key` - `x-auth-token` -### Body Field Redaction - -JSON body fields are recursively redacted. For example, with `.redact_body_field("password")`: - -```json -// Before redaction -{"user": {"name": "alice", "password": "secret123"}} - -// After redaction -{"user": {"name": "alice", "password": "[REDACTED]"}} -``` - -## Custom Store +JSON body redaction recursive çalışır; örneğin `password` alanı iç içe nesnelerde de maskelenir. -### File-System Store +## Kalıcı saklama için filesystem store -For persistent storage across restarts: +Geliştirici makinesi yeniden başlasa bile kayıtların kalmasını istiyorsanız filesystem store kullanın: ```rust,ignore -use rustapi_rs::replay::{ReplayLayer, FsReplayStore, FsReplayStoreConfig}; -use rustapi_core::replay::ReplayConfig; +use rustapi_rs::extras::replay::{ + FsReplayStore, FsReplayStoreConfig, ReplayConfig, ReplayLayer, +}; let config = ReplayConfig::new() .enabled(true) - .admin_token("my-secret-token"); + .admin_token("local-replay-token"); let fs_store = FsReplayStore::new(FsReplayStoreConfig { directory: "./replay-data".into(), - max_file_size: Some(10 * 1024 * 1024), // 10MB per file + max_file_size: Some(10 * 1024 * 1024), create_if_missing: true, }); -let layer = ReplayLayer::new(config).with_store(fs_store); +let replay = ReplayLayer::new(config).with_store(fs_store); ``` -### Implementing a Custom Store +## Özel backend yazmak isterseniz -Implement the `ReplayStore` trait for custom backends (Redis, database, etc.): +Redis, object storage ya da kurumsal bir audit backend'i kullanmak istiyorsanız `ReplayStore` trait'ini uygulayın: ```rust,ignore use async_trait::async_trait; -use rustapi_core::replay::{ +use rustapi_rs::extras::replay::{ ReplayEntry, ReplayQuery, ReplayStore, ReplayStoreResult, }; -struct MyCustomStore { - // your fields -} +#[derive(Clone)] +struct MyCustomStore; #[async_trait] impl ReplayStore for MyCustomStore { async fn store(&self, entry: ReplayEntry) -> ReplayStoreResult<()> { - // Store the entry + let _ = entry; Ok(()) } async fn get(&self, id: &str) -> ReplayStoreResult> { - // Retrieve by ID + let _ = id; Ok(None) } async fn list(&self, query: &ReplayQuery) -> ReplayStoreResult> { - // List with filtering + let _ = query; Ok(vec![]) } async fn delete(&self, id: &str) -> ReplayStoreResult { - // Delete by ID + let _ = id; Ok(false) } @@ -252,7 +293,7 @@ impl ReplayStore for MyCustomStore { } async fn delete_before(&self, timestamp_ms: u64) -> ReplayStoreResult { - // Delete entries older than timestamp + let _ = timestamp_ms; Ok(0) } @@ -262,22 +303,33 @@ impl ReplayStore for MyCustomStore { } ``` -## Security +## Doğrulama kontrol listesi + +Replay kurulumundan sonra şu kısa kontrolü yapın: + +1. uygulamaya bir istek gönderin +2. `cargo rustapi replay list -t ` ile girdiyi görün +3. `cargo rustapi replay show -t ` ile body/header kaydını doğrulayın +4. `cargo rustapi replay diff -t -T ` ile karşılaştırma alın + +Bu dört adım başarılıysa workflow hazırdır. + +## Güvenlik özeti -The replay system has multiple security layers built in: +Replay sistemi birden fazla koruma ile gelir: -1. **Disabled by default**: Recording is off (`enabled: false`) until explicitly enabled -2. **Admin token required**: All `/__rustapi/replays` endpoints require a valid bearer token. Requests without the token get a `401 Unauthorized` response -3. **Header redaction**: `authorization`, `cookie`, `x-api-key`, and `x-auth-token` values are replaced with `[REDACTED]` before storage -4. **Body field redaction**: Sensitive JSON fields (e.g., `password`, `ssn`) can be configured for redaction -5. **TTL enforcement**: Entries are automatically deleted after the configured TTL (default: 1 hour) -6. **Body size limits**: Request (64KB) and response (256KB) bodies are truncated to prevent memory issues -7. **Bounded storage**: The in-memory store uses a ring buffer with FIFO eviction +1. **Varsayılan kapalıdır**: `enabled(false)` ile başlar. +2. **Admin token zorunludur**: admin endpoint'leri bearer token ister. +3. **Header redaction vardır**: hassas header'lar maskelenir. +4. **Body field redaction vardır**: JSON alanları seçmeli maskelenebilir. +5. **TTL uygulanır**: eski kayıtlar otomatik temizlenir. +6. **Body boyutu sınırlandırılır**: request/response capture sınırlıdır. +7. **Bounded storage kullanılır**: in-memory store FIFO eviction ile sınırlıdır. -**Recommendations**: +Öneriler: -- Use only in development/staging environments -- Use a strong, unique admin token -- Keep TTL short -- Add application-specific sensitive fields to the redaction list -- Monitor memory usage when using the in-memory store with large capacity values +- replay'i herkese açık production ingress arkasında açmayın +- kısa TTL kullanın +- uygulamaya özel gizli alanları redaction listesine ekleyin +- büyük kapasite ile in-memory store kullanıyorsanız bellek tüketimini izleyin +- incident sonrasında replay kaydını kapatmayı düşünün diff --git a/tasks.md b/tasks.md index 58eb3d27..e69de29b 100644 --- a/tasks.md +++ b/tasks.md @@ -1,100 +0,0 @@ -# RustAPI Tasks - -Bu dosya, audit sonrası uygulanacak işleri tek yerde toplar. Tamamlanan maddeler işaretlidir. - -## Tamamlananlar - -### Production baseline - phase 1 -- [x] `RustApi` builder'a standart health probe desteği ekle -- [x] `/health`, `/ready`, `/live` endpoint'lerini built-in olarak sun -- [x] `HealthEndpointConfig` ile probe path'lerini özelleştirilebilir yap -- [x] `with_health_check(...)` ile custom dependency health check bağlanabilsin -- [x] unhealthy readiness durumunda `503 Service Unavailable` döndür -- [x] health endpoint'leri için entegrasyon testleri ekle -- [x] `README.md` ve `docs/GETTING_STARTED.md` içinde health probe kullanımını dokümante et - -### Production baseline - phase 2 -- [x] tek çağrıda production başlangıç ayarlarını açan preset ekle -- [x] `production_defaults("service-name")` API'sini ekle -- [x] `ProductionDefaultsConfig` ile preset davranışını konfigüre edilebilir yap -- [x] preset içinde `RequestIdLayer` etkinleştir -- [x] preset içinde `TracingLayer` etkinleştir -- [x] tracing span'lerine `service` ve `environment` alanlarını ekle -- [x] opsiyonel `version` bilgisini preset üzerinden health/tracing tarafına bağla -- [x] yeni public tipleri `rustapi-core` ve `rustapi-rs` facade üzerinden export et -- [x] production preset için entegrasyon testleri ekle -- [x] production preset kullanımını README ve Getting Started içinde dokümante et - -### Doğrulama -- [x] `cargo test -p rustapi-core --test health_endpoints --test status_page` -- [x] `cargo test -p rustapi-core --test production_defaults --test health_endpoints --test status_page` - -## Kritik sıradaki işler - -### Kimlik doğrulama ve session hikâyesi -- [x] built-in session store tasarla -- [x] memory-backed session store ekle -- [x] Redis-backed session store ekle -- [x] cookie + session extractor/middleware akışını resmileştir -- [x] login/logout/session refresh örnekleri ekle -- [x] OIDC / OAuth2 üretim rehberi yaz - -### Production güveni ve operasyonel netlik -- [x] resmi production checklist dokümanı yaz -- [x] recommended production baseline rehberi yaz -- [x] graceful shutdown + draining davranışını tek rehberde topla -- [x] deployment health/readiness/liveness önerilerini cookbook'a ekle -- [x] observability için golden config örneği yayınla - -### Performans güvenilirliği -- [x] benchmark iddialarını tek authoritative kaynağa taşı -- [x] README / docs / release notları arasındaki performans sayılarını senkronize et -- [x] p50/p95/p99 latency benchmark çıktıları ekle -- [x] feature-cost benchmark matrisi çıkar -- [x] execution path (ultra fast / fast / full) benchmark karşılaştırması ekle - -## Yüksek etkili DX işleri - -### Resmi örnekler -- [x] `crates/rustapi-rs/examples/full_crud_api.rs` ekle -- [x] `crates/rustapi-rs/examples/auth_api.rs` ekle -- [x] `crates/rustapi-rs/examples/streaming_api.rs` ekle -- [x] `crates/rustapi-rs/examples/jobs_api.rs` ekle -- [x] examples için index/README ekle - -### Dokümantasyon ve discoverability -- [x] macro attribute reference yaz (`#[tag]`, `#[summary]`, `#[param]`, `#[errors]`) -- [x] custom extractor cookbook rehberi yaz -- [x] error handling cookbook rehberi yaz -- [x] observability cookbook rehberi yaz -- [x] middleware debugging rehberi yaz -- [x] Axum -> RustAPI migration guide yaz -- [x] Actix -> RustAPI migration guide yaz - -### Data / DB guidance -- [x] SQLx / Diesel / SeaORM tercih rehberi yaz -- [x] migration strategy rehberi yaz -- [x] connection pooling önerilerini dokümante et - -## Nice-to-have / ekosistem büyütme - -### Runtime ve protocol geliştirmeleri -- [ ] streaming multipart upload desteği ekle -- [ ] büyük dosya yükleme için memory-safe akış tasarla -- [ ] GraphQL integration araştır ve adapter tasarla -- [ ] gelişmiş rate limiting stratejileri ekle (sliding window / token bucket) - -### CLI ve tooling -- [ ] `cargo rustapi doctor` komutunu production checklist ile hizala -- [ ] deploy/config doctor çıktısını geliştir -- [ ] feature preset scaffold'ları ekle (`prod-api`, `ai-api`, `realtime-api`) -- [ ] replay / benchmark / observability akışlarını CLI'dan erişilebilir yap - -### Farklılaştırıcı ürünleşme -- [ ] adaptive execution model için görünür profiling/debug UX tasarla -- [ ] TOON/AI-first API deneyimini preset + örnek + tooling ile ürünleştir -- [ ] replay/time-travel debugging için resmi workflow rehberi yaz - -## Notlar -- Tamamlanan maddeler bu branch'te uygulanmış ve test edilmiş işleri temsil eder. -- Bir sonraki en yüksek kaldıraçlı uygulama dilimi: **session store + auth/session story** veya **production checklist + observability guide**. From 409361e8d7e3efd83284849160b7464d377da732 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 8 Mar 2026 22:20:12 +0300 Subject: [PATCH 4/7] Add tokio sync, replay cfg and code formatting Enable the `sync` feature for tokio in cargo-rustapi and gate the Replay CLI subcommand/tests behind cfg(feature = "replay"). Additionally apply a series of small refactors and formatting fixes across the workspace: reflowed long expressions, added/removed trailing commas, normalized imports and multi-line signatures, and cleaned up test invocations and assertions. Changes touch cargo-rustapi, rustapi-core, rustapi-extras, rustapi-rs and related examples/tests; they are primarily stylistic and readability improvements (with the notable functional addition of the tokio `sync` feature and conditional replay wiring). --- crates/cargo-rustapi/Cargo.toml | 2 +- crates/cargo-rustapi/src/cli.rs | 6 +- crates/cargo-rustapi/src/commands/bench.rs | 5 +- crates/cargo-rustapi/src/commands/doctor.rs | 148 ++++++++++-------- crates/cargo-rustapi/src/commands/new.rs | 14 +- crates/cargo-rustapi/tests/cli_tests.rs | 25 +-- crates/rustapi-core/examples/perf_snapshot.rs | 43 +++-- crates/rustapi-core/src/app.rs | 5 +- crates/rustapi-core/src/multipart.rs | 18 ++- crates/rustapi-core/tests/health_endpoints.rs | 12 +- .../rustapi-core/tests/production_defaults.rs | 13 +- crates/rustapi-extras/src/rate_limit/mod.rs | 143 ++++++++++------- crates/rustapi-extras/src/session/mod.rs | 45 +++--- crates/rustapi-rs/examples/full_crud_api.rs | 15 +- crates/rustapi-rs/examples/jobs_api.rs | 5 +- crates/rustapi-rs/examples/streaming_api.rs | 53 ++++--- crates/rustapi-rs/src/lib.rs | 47 +++--- 17 files changed, 346 insertions(+), 253 deletions(-) diff --git a/crates/cargo-rustapi/Cargo.toml b/crates/cargo-rustapi/Cargo.toml index c927c926..b9a521b9 100644 --- a/crates/cargo-rustapi/Cargo.toml +++ b/crates/cargo-rustapi/Cargo.toml @@ -30,7 +30,7 @@ notify = "7.0" notify-debouncer-mini = "0.5" # Async -tokio = { workspace = true, features = ["process", "fs", "macros", "rt-multi-thread", "time", "signal"] } +tokio = { workspace = true, features = ["process", "fs", "macros", "rt-multi-thread", "time", "signal", "sync"] } # Serialization serde = { workspace = true } diff --git a/crates/cargo-rustapi/src/cli.rs b/crates/cargo-rustapi/src/cli.rs index 3252a836..ab113e79 100644 --- a/crates/cargo-rustapi/src/cli.rs +++ b/crates/cargo-rustapi/src/cli.rs @@ -1,11 +1,11 @@ //! CLI argument parsing +#[cfg(feature = "replay")] +use crate::commands::ReplayArgs; use crate::commands::{ self, AddArgs, BenchArgs, ClientArgs, DeployArgs, DoctorArgs, GenerateArgs, MigrateArgs, NewArgs, ObservabilityArgs, RunArgs, WatchArgs, }; -#[cfg(feature = "replay")] -use crate::commands::ReplayArgs; use clap::{Parser, Subcommand}; /// The official CLI tool for the RustAPI framework. Scaffold new projects, run development servers, and manage database migrations. @@ -67,6 +67,7 @@ enum Commands { Deploy(DeployArgs), /// Replay debugging commands (time-travel debugging) + #[cfg(feature = "replay")] #[command(subcommand)] Replay(ReplayArgs), } @@ -87,6 +88,7 @@ impl Cli { Commands::Docs { port } => commands::open_docs(port).await, Commands::Client(args) => commands::client(args).await, Commands::Deploy(args) => commands::deploy(args).await, + #[cfg(feature = "replay")] Commands::Replay(args) => commands::replay(args).await, } } diff --git a/crates/cargo-rustapi/src/commands/bench.rs b/crates/cargo-rustapi/src/commands/bench.rs index 9589fe00..252696f3 100644 --- a/crates/cargo-rustapi/src/commands/bench.rs +++ b/crates/cargo-rustapi/src/commands/bench.rs @@ -56,7 +56,10 @@ pub async fn bench(args: BenchArgs) -> Result<()> { command.env("RUSTAPI_PERF_ITERS", iterations.to_string()); } - let status = command.status().await.context("Failed to launch benchmark workflow")?; + let status = command + .status() + .await + .context("Failed to launch benchmark workflow")?; if !status.success() { bail!("Benchmark workflow exited with status {}", status); } diff --git a/crates/cargo-rustapi/src/commands/doctor.rs b/crates/cargo-rustapi/src/commands/doctor.rs index 8721ed50..a8fe2bb4 100644 --- a/crates/cargo-rustapi/src/commands/doctor.rs +++ b/crates/cargo-rustapi/src/commands/doctor.rs @@ -89,34 +89,34 @@ pub async fn doctor(args: DoctorArgs) -> Result<()> { println!("{}", style("Toolchain").bold()); checks.push(check_tool("rustc", &["--version"], "Rust compiler", true).await); - checks.push(check_tool( - "cargo", - &["--version"], - "Cargo package manager", - true, - ) - .await); - checks.push(check_tool( - "cargo", - &["watch", "--version"], - "cargo-watch (for hot reload)", - false, - ) - .await); - checks.push(check_tool( - "docker", - &["--version"], - "Docker (for containerization)", - false, - ) - .await); - checks.push(check_tool( - "sqlx", - &["--version"], - "sqlx-cli (for database migrations)", - false, - ) - .await); + checks.push(check_tool("cargo", &["--version"], "Cargo package manager", true).await); + checks.push( + check_tool( + "cargo", + &["watch", "--version"], + "cargo-watch (for hot reload)", + false, + ) + .await, + ); + checks.push( + check_tool( + "docker", + &["--version"], + "Docker (for containerization)", + false, + ) + .await, + ); + checks.push( + check_tool( + "sqlx", + &["--version"], + "sqlx-cli (for database migrations)", + false, + ) + .await, + ); for check in &checks { print_check(check); @@ -280,10 +280,7 @@ fn build_project_checks(workspace_root: &Path) -> Result> { } checks.push(if signals.production_defaults { - DoctorCheck::pass( - "Application baseline", - "production_defaults usage detected", - ) + DoctorCheck::pass("Application baseline", "production_defaults usage detected") } else { DoctorCheck::warn( "Application baseline", @@ -327,17 +324,21 @@ fn build_project_checks(workspace_root: &Path) -> Result> { ) }); - checks.push(if (signals.production_defaults || signals.request_id) && (signals.production_defaults || signals.tracing) { - DoctorCheck::pass( - "Request IDs and tracing", - "Request ID and tracing signals detected", - ) - } else { - DoctorCheck::warn( - "Request IDs and tracing", - "RequestIdLayer/tracing signals were not clearly detected", - ) - }); + checks.push( + if (signals.production_defaults || signals.request_id) + && (signals.production_defaults || signals.tracing) + { + DoctorCheck::pass( + "Request IDs and tracing", + "Request ID and tracing signals detected", + ) + } else { + DoctorCheck::warn( + "Request IDs and tracing", + "RequestIdLayer/tracing signals were not clearly detected", + ) + }, + ); checks.push(if signals.structured_logging || signals.otel { DoctorCheck::pass( @@ -351,17 +352,19 @@ fn build_project_checks(workspace_root: &Path) -> Result> { ) }); - checks.push(if signals.rate_limit || signals.security_headers || signals.timeout || signals.cors { - DoctorCheck::pass( - "Edge protections", - "Detected timeout, rate limit, CORS, or security header configuration", - ) - } else { - DoctorCheck::warn( - "Edge protections", - "No timeout, rate limit, CORS, or security header configuration was detected", - ) - }); + checks.push( + if signals.rate_limit || signals.security_headers || signals.timeout || signals.cors { + DoctorCheck::pass( + "Edge protections", + "Detected timeout, rate limit, CORS, or security header configuration", + ) + } else { + DoctorCheck::warn( + "Edge protections", + "No timeout, rate limit, CORS, or security header configuration was detected", + ) + }, + ); checks.push(if signals.body_limit { DoctorCheck::pass( @@ -401,7 +404,11 @@ fn scan_workspace_signals(workspace_root: &Path) -> Result { ); signals.health_endpoints |= contains_any( &contents, - &[".health_endpoints(", ".health_endpoint_config(", "HealthEndpointConfig"], + &[ + ".health_endpoints(", + ".health_endpoint_config(", + "HealthEndpointConfig", + ], ); signals.health_checks |= contents.contains(".with_health_check("); signals.request_id |= contents.contains("RequestIdLayer"); @@ -414,10 +421,8 @@ fn scan_workspace_signals(workspace_root: &Path) -> Result { ); signals.otel |= contains_any(&contents, &["OtelLayer", "otel("]); signals.rate_limit |= contains_any(&contents, &["RateLimitLayer", "rate_limit("]); - signals.security_headers |= contains_any( - &contents, - &["SecurityHeadersLayer", "security_headers("], - ); + signals.security_headers |= + contains_any(&contents, &["SecurityHeadersLayer", "security_headers("]); signals.timeout |= contains_any(&contents, &["TimeoutLayer", "timeout("]); signals.cors |= contains_any(&contents, &["CorsLayer", "cors("]); signals.body_limit |= contains_any(&contents, &["BodyLimitLayer", ".body_limit("]); @@ -463,7 +468,10 @@ fn should_scan(path: &Path) -> bool { return true; }; - !matches!(name, ".git" | "target" | "node_modules" | ".next" | "dist" | "build") + !matches!( + name, + ".git" | "target" | "node_modules" | ".next" | "dist" | "build" + ) } fn is_scannable_file(path: &Path) -> bool { @@ -500,7 +508,11 @@ mod tests { #[test] fn scan_workspace_signals_detects_production_patterns() { let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname='demo'\nversion='0.1.0'\n").unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname='demo'\nversion='0.1.0'\n", + ) + .unwrap(); fs::write(dir.path().join(".env"), "RUSTAPI_ENV=production\n").unwrap(); let src_dir = dir.path().join("src"); @@ -536,12 +548,20 @@ mod tests { #[test] fn build_project_checks_warns_when_signals_are_missing() { let dir = tempdir().unwrap(); - fs::write(dir.path().join("Cargo.toml"), "[package]\nname='demo'\nversion='0.1.0'\n").unwrap(); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname='demo'\nversion='0.1.0'\n", + ) + .unwrap(); fs::create_dir_all(dir.path().join("src")).unwrap(); fs::write(dir.path().join("src").join("main.rs"), "fn main() {}\n").unwrap(); let checks = build_project_checks(dir.path()).unwrap(); - assert!(checks.iter().any(|check| check.status == DoctorStatus::Warn)); - assert!(checks.iter().any(|check| check.name == "Application baseline")); + assert!(checks + .iter() + .any(|check| check.status == DoctorStatus::Warn)); + assert!(checks + .iter() + .any(|check| check.name == "Application baseline")); } } diff --git a/crates/cargo-rustapi/src/commands/new.rs b/crates/cargo-rustapi/src/commands/new.rs index 6836928f..168cd186 100644 --- a/crates/cargo-rustapi/src/commands/new.rs +++ b/crates/cargo-rustapi/src/commands/new.rs @@ -118,7 +118,12 @@ pub async fn new_project(mut args: NewArgs) -> Result<()> { // Get features let features = if let Some(features) = args.features { - merge_unique_features(preset.map(ProjectPreset::recommended_features).unwrap_or_default(), features) + merge_unique_features( + preset + .map(ProjectPreset::recommended_features) + .unwrap_or_default(), + features, + ) } else if args.yes { preset .map(ProjectPreset::recommended_features) @@ -153,7 +158,12 @@ pub async fn new_project(mut args: NewArgs) -> Result<()> { let defaults = defaults .into_iter() .enumerate() - .map(|(index, default)| default || preset_features.iter().any(|feature| feature == available[index])) + .map(|(index, default)| { + default + || preset_features + .iter() + .any(|feature| feature == available[index]) + }) .collect::>(); let selections = dialoguer::MultiSelect::with_theme(&theme) diff --git a/crates/cargo-rustapi/tests/cli_tests.rs b/crates/cargo-rustapi/tests/cli_tests.rs index 2b8d8fc7..7d4a8dbb 100644 --- a/crates/cargo-rustapi/tests/cli_tests.rs +++ b/crates/cargo-rustapi/tests/cli_tests.rs @@ -112,13 +112,7 @@ mod new_command { cargo_rustapi() .current_dir(dir.path()) - .args([ - "new", - project_name, - "--preset", - "prod-api", - "--yes", - ]) + .args(["new", project_name, "--preset", "prod-api", "--yes"]) .assert() .success(); @@ -140,13 +134,7 @@ mod new_command { cargo_rustapi() .current_dir(dir.path()) - .args([ - "new", - project_name, - "--preset", - "ai-api", - "--yes", - ]) + .args(["new", project_name, "--preset", "ai-api", "--yes"]) .assert() .success(); @@ -166,13 +154,7 @@ mod new_command { cargo_rustapi() .current_dir(dir.path()) - .args([ - "new", - project_name, - "--preset", - "realtime-api", - "--yes", - ]) + .args(["new", project_name, "--preset", "realtime-api", "--yes"]) .assert() .success(); @@ -282,6 +264,7 @@ mod observability_command { } } +#[cfg(feature = "replay")] mod replay_command { use super::*; diff --git a/crates/rustapi-core/examples/perf_snapshot.rs b/crates/rustapi-core/examples/perf_snapshot.rs index 26481f29..16d5d2f6 100644 --- a/crates/rustapi-core/examples/perf_snapshot.rs +++ b/crates/rustapi-core/examples/perf_snapshot.rs @@ -2,7 +2,9 @@ use bytes::Bytes; use http::{Extensions, Method, StatusCode}; use rustapi_core::interceptor::{RequestInterceptor, ResponseInterceptor}; use rustapi_core::middleware::{BoxedNext, MiddlewareLayer}; -use rustapi_core::{get, BodyVariant, IntoResponse, PathParams, Request, Response, RouteMatch, RustApi}; +use rustapi_core::{ + get, BodyVariant, IntoResponse, PathParams, Request, Response, RouteMatch, RustApi, +}; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -76,7 +78,12 @@ struct ScenarioResult { mean_us: f64, } -fn scenario(name: &'static str, path_kind: &'static str, features: &'static str, app: RustApi) -> Scenario { +fn scenario( + name: &'static str, + path_kind: &'static str, + features: &'static str, + app: RustApi, +) -> Scenario { let layers = app.layers().clone(); let interceptors = app.interceptors().clone(); let router = app.into_router(); @@ -114,9 +121,7 @@ async fn route_request_direct( method: &Method, ) -> Response { match router.match_route(path, method) { - RouteMatch::Found { handler, .. } => { - handler(request).await - } + RouteMatch::Found { handler, .. } => handler(request).await, RouteMatch::NotFound => rustapi_core::ApiError::not_found("Not found").into_response(), RouteMatch::MethodNotAllowed { allowed } => { let allowed_str: Vec<&str> = allowed.iter().map(|m| m.as_str()).collect(); @@ -128,7 +133,10 @@ async fn route_request_direct( .into_response(); response.headers_mut().insert( http::header::ALLOW, - allowed_str.join(", ").parse().expect("allow header should parse"), + allowed_str + .join(", ") + .parse() + .expect("allow header should parse"), ); response } @@ -183,7 +191,11 @@ async fn measure_scenario( let response = execute_scenario_request(scenario).await; let elapsed = request_start.elapsed(); - assert_eq!(response.status(), StatusCode::OK, "benchmark scenario must stay healthy"); + assert_eq!( + response.status(), + StatusCode::OK, + "benchmark scenario must stay healthy" + ); latencies_ns.push(elapsed.as_nanos() as u64); std::hint::black_box(response.status()); @@ -233,9 +245,7 @@ fn print_results(results: &[ScenarioResult]) { println!( "| Scenario | Execution path | Features | Req/s | Mean (µs) | p50 (µs) | p95 (µs) | p99 (µs) |" ); - println!( - "|---|---|---|---:|---:|---:|---:|---:|" - ); + println!("|---|---|---|---:|---:|---:|---:|---:|"); for result in results { println!( @@ -255,17 +265,14 @@ fn print_results(results: &[ScenarioResult]) { if let Some(baseline) = results.iter().find(|result| result.name == "baseline") { println!("## Relative overhead vs baseline"); println!(); - println!("| Scenario | Req/s delta | p99 delta |", - ); + println!("| Scenario | Req/s delta | p99 delta |",); println!("|---|---:|---:|"); for result in results { let req_delta = ((result.throughput_req_s / baseline.throughput_req_s) - 1.0) * 100.0; let p99_delta = ((result.p99_us / baseline.p99_us) - 1.0) * 100.0; println!( "| {} | {:+.2}% | {:+.2}% |", - result.name, - req_delta, - p99_delta, + result.name, req_delta, p99_delta, ); } } @@ -308,7 +315,9 @@ async fn main() -> Result<(), Box> { "middleware_only", "full", "1 middleware layer", - RustApi::new().layer(NoopMiddleware).route("/hello", get(hello)), + RustApi::new() + .layer(NoopMiddleware) + .route("/hello", get(hello)), ), scenario( "full_stack_minimal", @@ -344,4 +353,4 @@ async fn main() -> Result<(), Box> { print_results(&results); Ok(()) -} \ No newline at end of file +} diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index cd10ab78..c4dba674 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -1110,10 +1110,7 @@ impl RustApi { let mut tracing_layer = crate::middleware::TracingLayer::with_level(config.tracing_level) .with_field("service", config.service_name.clone()) - .with_field( - "environment", - crate::error::get_environment().to_string(), - ); + .with_field("environment", crate::error::get_environment().to_string()); if let Some(version) = &config.version { tracing_layer = tracing_layer.with_field("version", version.clone()); diff --git a/crates/rustapi-core/src/multipart.rs b/crates/rustapi-core/src/multipart.rs index 2dba6313..7e3a46ac 100644 --- a/crates/rustapi-core/src/multipart.rs +++ b/crates/rustapi-core/src/multipart.rs @@ -27,8 +27,8 @@ use crate::stream::StreamingBody; use bytes::Bytes; use futures_util::stream; use http::StatusCode; -use std::path::Path; use std::error::Error as _; +use std::path::Path; use tokio::io::AsyncWriteExt; /// Maximum file size (default: 10MB) @@ -246,7 +246,11 @@ impl<'a> StreamingMultipartField<'a> { } /// Save the field to a directory using either the provided filename or the uploaded name. - pub async fn save_to(&mut self, dir: impl AsRef, filename: Option<&str>) -> Result { + pub async fn save_to( + &mut self, + dir: impl AsRef, + filename: Option<&str>, + ) -> Result { let dir = dir.as_ref(); tokio::fs::create_dir_all(dir) @@ -256,7 +260,9 @@ impl<'a> StreamingMultipartField<'a> { let final_filename = filename .map(|value| value.to_string()) .or_else(|| self.file_name().map(|value| value.to_string())) - .ok_or_else(|| ApiError::bad_request("No filename provided and field has no filename"))?; + .ok_or_else(|| { + ApiError::bad_request("No filename provided and field has no filename") + })?; let safe_filename = sanitize_filename(&final_filename); let file_path = dir.join(&safe_filename); @@ -773,7 +779,8 @@ mod tests { boundary: &str, config: MultipartConfig, ) -> StreamingMultipart { - let stream = StreamingBody::from_stream(chunked_body_stream(body, 7), Some(config.max_size)); + let stream = + StreamingBody::from_stream(chunked_body_stream(body, 7), Some(config.max_size)); StreamingMultipart::new(stream, boundary.to_string(), config) } @@ -956,7 +963,8 @@ mod tests { ); let mut file = multipart.next_field().await.unwrap().unwrap(); - let temp_dir = std::env::temp_dir().join(format!("rustapi-streaming-upload-{}", uuid::Uuid::new_v4())); + let temp_dir = + std::env::temp_dir().join(format!("rustapi-streaming-upload-{}", uuid::Uuid::new_v4())); let saved_path = file.save_to(&temp_dir, None).await.unwrap(); let saved = tokio::fs::read_to_string(&saved_path).await.unwrap(); diff --git a/crates/rustapi-core/tests/health_endpoints.rs b/crates/rustapi-core/tests/health_endpoints.rs index 2a311067..a202328e 100644 --- a/crates/rustapi-core/tests/health_endpoints.rs +++ b/crates/rustapi-core/tests/health_endpoints.rs @@ -36,7 +36,11 @@ async fn test_default_health_endpoints_are_available() { assert_eq!(res.status(), 200, "{} should return 200", path); let body: serde_json::Value = res.json().await.unwrap(); - assert!(body.get("status").is_some(), "{} should include status", path); + assert!( + body.get("status").is_some(), + "{} should include status", + path + ); assert!( body.get("timestamp").is_some(), "{} should include timestamp", @@ -51,7 +55,9 @@ async fn test_default_health_endpoints_are_available() { #[tokio::test] async fn test_unhealthy_readiness_returns_503() { let health = HealthCheckBuilder::new(false) - .add_check("database", || async { HealthStatus::unhealthy("database offline") }) + .add_check("database", || async { + HealthStatus::unhealthy("database offline") + }) .build(); let app = RustApi::new().with_health_check(health); @@ -130,4 +136,4 @@ async fn test_custom_health_endpoint_paths() { tx.send(()).unwrap(); let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; -} \ No newline at end of file +} diff --git a/crates/rustapi-core/tests/production_defaults.rs b/crates/rustapi-core/tests/production_defaults.rs index 916aa335..d7bb1ca9 100644 --- a/crates/rustapi-core/tests/production_defaults.rs +++ b/crates/rustapi-core/tests/production_defaults.rs @@ -13,7 +13,11 @@ async fn test_production_defaults_enable_request_id_and_health_probes() { .production_defaults("users-api") .route("/hello", get(hello)); - assert_eq!(app.layers().len(), 2, "request ID and tracing layers should be installed"); + assert_eq!( + app.layers().len(), + 2, + "request ID and tracing layers should be installed" + ); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); @@ -106,7 +110,10 @@ async fn test_production_defaults_custom_config_applies_version_and_custom_paths .await .expect("healthz request failed"); let body: serde_json::Value = res.json().await.unwrap(); - assert_eq!(body.get("version"), Some(&serde_json::Value::String("1.2.3".to_string()))); + assert_eq!( + body.get("version"), + Some(&serde_json::Value::String("1.2.3".to_string())) + ); tx.send(()).unwrap(); let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; @@ -122,4 +129,4 @@ fn test_production_defaults_can_disable_optional_parts() { ); assert_eq!(app.layers().len(), 0); -} \ No newline at end of file +} diff --git a/crates/rustapi-extras/src/rate_limit/mod.rs b/crates/rustapi-extras/src/rate_limit/mod.rs index 1914bf0e..6970ecf5 100644 --- a/crates/rustapi-extras/src/rate_limit/mod.rs +++ b/crates/rustapi-extras/src/rate_limit/mod.rs @@ -29,17 +29,9 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; /// Internal entry for tracking rate limit state per client. #[derive(Debug, Clone)] enum RateLimitEntry { - FixedWindow { - count: u32, - window_start: Instant, - }, - SlidingWindow { - requests: VecDeque, - }, - TokenBucket { - tokens: f64, - last_refill: Instant, - }, + FixedWindow { count: u32, window_start: Instant }, + SlidingWindow { requests: VecDeque }, + TokenBucket { tokens: f64, last_refill: Instant }, } #[derive(Debug, Clone, Copy)] @@ -89,7 +81,13 @@ impl RateLimitStore { .or_insert_with(|| RateLimitStore::new_entry(strategy, max_requests, now)); let decision = match (&mut *entry, strategy) { - (RateLimitEntry::FixedWindow { count, window_start }, RateLimitStrategy::FixedWindow) => { + ( + RateLimitEntry::FixedWindow { + count, + window_start, + }, + RateLimitStrategy::FixedWindow, + ) => { if now.duration_since(*window_start) >= window { *count = 0; *window_start = now; @@ -127,7 +125,13 @@ impl RateLimitStore { retry_after, } } - (RateLimitEntry::TokenBucket { tokens, last_refill }, RateLimitStrategy::TokenBucket) => { + ( + RateLimitEntry::TokenBucket { + tokens, + last_refill, + }, + RateLimitStrategy::TokenBucket, + ) => { let refill_rate = max_requests as f64 / window.as_secs_f64().max(f64::EPSILON); let elapsed = now.duration_since(*last_refill).as_secs_f64(); *tokens = (*tokens + elapsed * refill_rate).min(max_requests as f64); @@ -174,55 +178,71 @@ impl RateLimitStore { ) -> Option { let now = Instant::now(); - self.entries.get(&ip).map(|entry| match (&*entry, strategy) { - (RateLimitEntry::FixedWindow { count, window_start }, RateLimitStrategy::FixedWindow) => { - let current_count = if now.duration_since(*window_start) >= window { - 0 - } else { - *count - }; - - RateLimitInfo { - limit: max_requests, - remaining: max_requests.saturating_sub(current_count), - reset: unix_timestamp_after(window.saturating_sub(now.duration_since(*window_start))), + self.entries + .get(&ip) + .map(|entry| match (&*entry, strategy) { + ( + RateLimitEntry::FixedWindow { + count, + window_start, + }, + RateLimitStrategy::FixedWindow, + ) => { + let current_count = if now.duration_since(*window_start) >= window { + 0 + } else { + *count + }; + + RateLimitInfo { + limit: max_requests, + remaining: max_requests.saturating_sub(current_count), + reset: unix_timestamp_after( + window.saturating_sub(now.duration_since(*window_start)), + ), + } } - } - (RateLimitEntry::SlidingWindow { requests }, RateLimitStrategy::SlidingWindow) => { - let active = requests - .iter() - .copied() - .filter(|timestamp| now.duration_since(*timestamp) < window) - .collect::>(); - let retry_after = active - .first() - .map(|oldest| window.saturating_sub(now.duration_since(*oldest))) - .unwrap_or(Duration::ZERO); - - RateLimitInfo { - limit: max_requests, - remaining: max_requests.saturating_sub(active.len() as u32), - reset: unix_timestamp_after(retry_after), + (RateLimitEntry::SlidingWindow { requests }, RateLimitStrategy::SlidingWindow) => { + let active = requests + .iter() + .copied() + .filter(|timestamp| now.duration_since(*timestamp) < window) + .collect::>(); + let retry_after = active + .first() + .map(|oldest| window.saturating_sub(now.duration_since(*oldest))) + .unwrap_or(Duration::ZERO); + + RateLimitInfo { + limit: max_requests, + remaining: max_requests.saturating_sub(active.len() as u32), + reset: unix_timestamp_after(retry_after), + } } - } - (RateLimitEntry::TokenBucket { tokens, last_refill }, RateLimitStrategy::TokenBucket) => { - let refill_rate = max_requests as f64 / window.as_secs_f64().max(f64::EPSILON); - let elapsed = now.duration_since(*last_refill).as_secs_f64(); - let available = (*tokens + elapsed * refill_rate).min(max_requests as f64); - let retry_after = next_token_after(available, max_requests, refill_rate); - - RateLimitInfo { - limit: max_requests, - remaining: available.floor().max(0.0).min(max_requests as f64) as u32, - reset: unix_timestamp_after(retry_after), + ( + RateLimitEntry::TokenBucket { + tokens, + last_refill, + }, + RateLimitStrategy::TokenBucket, + ) => { + let refill_rate = max_requests as f64 / window.as_secs_f64().max(f64::EPSILON); + let elapsed = now.duration_since(*last_refill).as_secs_f64(); + let available = (*tokens + elapsed * refill_rate).min(max_requests as f64); + let retry_after = next_token_after(available, max_requests, refill_rate); + + RateLimitInfo { + limit: max_requests, + remaining: available.floor().max(0.0).min(max_requests as f64) as u32, + reset: unix_timestamp_after(retry_after), + } } - } - _ => RateLimitInfo { - limit: max_requests, - remaining: max_requests, - reset: unix_timestamp_after(Duration::ZERO), - }, - }) + _ => RateLimitInfo { + limit: max_requests, + remaining: max_requests, + reset: unix_timestamp_after(Duration::ZERO), + }, + }) } fn new_entry(strategy: RateLimitStrategy, max_requests: u32, now: Instant) -> RateLimitEntry { @@ -907,7 +927,10 @@ mod tests { let request = create_test_request(Some("10.0.0.2")); let response = stack.execute(request, create_success_handler()).await; assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.headers().get("X-RateLimit-Remaining").unwrap(), "0"); + assert_eq!( + response.headers().get("X-RateLimit-Remaining").unwrap(), + "0" + ); }); } diff --git a/crates/rustapi-extras/src/session/mod.rs b/crates/rustapi-extras/src/session/mod.rs index 92aba915..80bf475d 100644 --- a/crates/rustapi-extras/src/session/mod.rs +++ b/crates/rustapi-extras/src/session/mod.rs @@ -384,7 +384,11 @@ impl Session { } /// Insert or replace a typed value. - pub async fn insert(&self, key: impl Into, value: T) -> SessionResult<()> { + pub async fn insert( + &self, + key: impl Into, + value: T, + ) -> SessionResult<()> { let mut guard = self.inner.lock().await; let value = serde_json::to_value(value) .map_err(|error| SessionError::Serialize(error.to_string()))?; @@ -441,10 +445,9 @@ impl std::fmt::Debug for Session { impl FromRequestParts for Session { fn from_request_parts(req: &Request) -> Result { - req.extensions() - .get::() - .cloned() - .ok_or_else(|| ApiError::internal("Session middleware is missing. Add SessionLayer first.")) + req.extensions().get::().cloned().ok_or_else(|| { + ApiError::internal("Session middleware is missing. Add SessionLayer first.") + }) } } @@ -542,7 +545,10 @@ where }; if should_persist { - let mut session_id = snapshot.id.clone().unwrap_or_else(|| Uuid::new_v4().to_string()); + let mut session_id = snapshot + .id + .clone() + .unwrap_or_else(|| Uuid::new_v4().to_string()); if snapshot.rotate_id { let rotated_id = Uuid::new_v4().to_string(); @@ -556,7 +562,8 @@ where session_id = rotated_id; } - let record = SessionRecord::new(session_id.clone(), snapshot.data.clone(), config.ttl); + let record = + SessionRecord::new(session_id.clone(), snapshot.data.clone(), config.ttl); if let Err(error) = store.save(record).await { return ApiError::from(error).into_response(); @@ -614,8 +621,8 @@ impl RedisSessionStore { /// Create a Redis session store from a connection URL. pub fn from_url(url: &str) -> SessionResult { - let client = redis::Client::open(url) - .map_err(|error| SessionError::Config(error.to_string()))?; + let client = + redis::Client::open(url).map_err(|error| SessionError::Config(error.to_string()))?; Ok(Self::new(client)) } @@ -717,10 +724,9 @@ fn append_session_cookie(response: &mut Response, config: &SessionConfig, sessio cookie = cookie.domain(domain.clone()); } - response.headers_mut().append( - header::SET_COOKIE, - cookie_header_value(cookie.build()), - ); + response + .headers_mut() + .append(header::SET_COOKIE, cookie_header_value(cookie.build())); } fn append_clear_cookie(response: &mut Response, config: &SessionConfig) { @@ -735,10 +741,9 @@ fn append_clear_cookie(response: &mut Response, config: &SessionConfig) { cookie = cookie.domain(domain.clone()); } - response.headers_mut().append( - header::SET_COOKIE, - cookie_header_value(cookie.build()), - ); + response + .headers_mut() + .append(header::SET_COOKIE, cookie_header_value(cookie.build())); } fn cookie_header_value(cookie: Cookie<'static>) -> HeaderValue { @@ -788,7 +793,9 @@ mod tests { async fn login(session: Session, body: Body) -> TestSessionResponse { let payload: LoginPayload = match serde_json::from_slice(&body) { Ok(payload) => payload, - Err(error) => return TestSessionResponse::Error(ApiError::bad_request(error.to_string())), + Err(error) => { + return TestSessionResponse::Error(ApiError::bad_request(error.to_string())) + } }; session.cycle_id().await; @@ -964,4 +971,4 @@ mod tests { assert_eq!(store.key("abc"), "custom:sessions:abc"); } -} \ No newline at end of file +} diff --git a/crates/rustapi-rs/examples/full_crud_api.rs b/crates/rustapi-rs/examples/full_crud_api.rs index 364f8f58..8611b579 100644 --- a/crates/rustapi-rs/examples/full_crud_api.rs +++ b/crates/rustapi-rs/examples/full_crud_api.rs @@ -1,6 +1,9 @@ use rustapi_rs::prelude::*; use std::collections::HashMap; -use std::sync::{atomic::{AtomicU64, Ordering}, Arc}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; use tokio::sync::RwLock; #[derive(Clone)] @@ -41,7 +44,10 @@ async fn list_todos(State(state): State) -> Json> { Json(items) } -async fn create_todo(State(state): State, Json(payload): Json) -> Created { +async fn create_todo( + State(state): State, + Json(payload): Json, +) -> Created { let id = state.next_id.fetch_add(1, Ordering::SeqCst); let item = TodoItem { id, @@ -106,7 +112,10 @@ async fn main() -> Result<(), Box> { todos: Arc::new(RwLock::new(HashMap::new())), }) .route("/todos", get(list_todos).post(create_todo)) - .route("/todos/{id}", get(get_todo).put(update_todo).delete(delete_todo)) + .route( + "/todos/{id}", + get(get_todo).put(update_todo).delete(delete_todo), + ) .run("127.0.0.1:3000") .await } diff --git a/crates/rustapi-rs/examples/jobs_api.rs b/crates/rustapi-rs/examples/jobs_api.rs index ec3632c0..6d4d03d1 100644 --- a/crates/rustapi-rs/examples/jobs_api.rs +++ b/crates/rustapi-rs/examples/jobs_api.rs @@ -12,7 +12,10 @@ use rustapi_rs::extras::jobs::{InMemoryBackend, Job, JobContext, JobQueue}; #[cfg(any(feature = "extras-jobs", feature = "jobs"))] use rustapi_rs::prelude::*; #[cfg(any(feature = "extras-jobs", feature = "jobs"))] -use std::sync::{atomic::{AtomicU64, Ordering}, Arc}; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; #[cfg(any(feature = "extras-jobs", feature = "jobs"))] #[derive(Clone)] diff --git a/crates/rustapi-rs/examples/streaming_api.rs b/crates/rustapi-rs/examples/streaming_api.rs index 109ce289..8ca22771 100644 --- a/crates/rustapi-rs/examples/streaming_api.rs +++ b/crates/rustapi-rs/examples/streaming_api.rs @@ -7,30 +7,37 @@ struct ProgressUpdate { message: String, } -async fn progress_feed() -> Sse>> { +async fn progress_feed( +) -> Sse>> { let events = vec![ - Ok::<_, Infallible>(SseEvent::json_data(&ProgressUpdate { - step: 1, - message: "queued".to_string(), - }) - .expect("json data should serialize") - .event("progress") - .id("1")), - Ok::<_, Infallible>(SseEvent::json_data(&ProgressUpdate { - step: 2, - message: "processing".to_string(), - }) - .expect("json data should serialize") - .event("progress") - .id("2")), - Ok::<_, Infallible>(SseEvent::json_data(&ProgressUpdate { - step: 3, - message: "done".to_string(), - }) - .expect("json data should serialize") - .event("complete") - .id("3") - .retry(2_000)), + Ok::<_, Infallible>( + SseEvent::json_data(&ProgressUpdate { + step: 1, + message: "queued".to_string(), + }) + .expect("json data should serialize") + .event("progress") + .id("1"), + ), + Ok::<_, Infallible>( + SseEvent::json_data(&ProgressUpdate { + step: 2, + message: "processing".to_string(), + }) + .expect("json data should serialize") + .event("progress") + .id("2"), + ), + Ok::<_, Infallible>( + SseEvent::json_data(&ProgressUpdate { + step: 3, + message: "done".to_string(), + }) + .expect("json data should serialize") + .event("complete") + .id("3") + .retry(2_000), + ), ]; sse_from_iter(events).keep_alive(KeepAlive::new()) diff --git a/crates/rustapi-rs/src/lib.rs b/crates/rustapi-rs/src/lib.rs index cb4dd719..89e92d92 100644 --- a/crates/rustapi-rs/src/lib.rs +++ b/crates/rustapi-rs/src/lib.rs @@ -25,16 +25,16 @@ pub mod core { pub use rustapi_core::EventBus; pub use rustapi_core::{ delete, delete_route, get, get_route, patch, patch_route, post, post_route, put, put_route, - route, serve_dir, sse_response, ApiError, AsyncValidatedJson, Body, BodyLimitLayer, - BodyStream, BodyVariant, ClientIp, Created, CursorPaginate, CursorPaginated, Environment, - Extension, FieldError, FromRequest, FromRequestParts, Handler, HandlerService, HeaderValue, - Headers, HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthEndpointConfig, - HealthStatus, Html, IntoResponse, Json, KeepAlive, MethodRouter, Multipart, - MultipartConfig, MultipartField, NoContent, Paginate, Paginated, Path, Query, Redirect, - ProductionDefaultsConfig, Request, RequestId, RequestIdLayer, Response, ResponseBody, - Result, Route, RouteHandler, RouteMatch, Router, RustApi, RustApiConfig, Sse, SseEvent, - State, StaticFile, StreamingMultipart, StreamingMultipartField, sse_from_iter, - StaticFileConfig, StatusCode, StreamBody, TracingLayer, Typed, TypedPath, UploadedFile, + route, serve_dir, sse_from_iter, sse_response, ApiError, AsyncValidatedJson, Body, + BodyLimitLayer, BodyStream, BodyVariant, ClientIp, Created, CursorPaginate, + CursorPaginated, Environment, Extension, FieldError, FromRequest, FromRequestParts, + Handler, HandlerService, HeaderValue, Headers, HealthCheck, HealthCheckBuilder, + HealthCheckResult, HealthEndpointConfig, HealthStatus, Html, IntoResponse, Json, KeepAlive, + MethodRouter, Multipart, MultipartConfig, MultipartField, NoContent, Paginate, Paginated, + Path, ProductionDefaultsConfig, Query, Redirect, Request, RequestId, RequestIdLayer, + Response, ResponseBody, Result, Route, RouteHandler, RouteMatch, Router, RustApi, + RustApiConfig, Sse, SseEvent, State, StaticFile, StaticFileConfig, StatusCode, StreamBody, + StreamingMultipart, StreamingMultipartField, TracingLayer, Typed, TypedPath, UploadedFile, ValidatedJson, WithStatus, }; @@ -193,8 +193,8 @@ pub mod extras { }; pub use rustapi_extras::replay; pub use rustapi_extras::replay::{ - FsReplayStore, FsReplayStoreConfig, InMemoryReplayStore, ReplayAdminAuth, - ReplayClient, ReplayClientError, ReplayLayer, RetentionJob, + FsReplayStore, FsReplayStoreConfig, InMemoryReplayStore, ReplayAdminAuth, ReplayClient, + ReplayClientError, ReplayLayer, RetentionJob, }; } @@ -211,8 +211,8 @@ pub mod extras { pub mod session { pub use rustapi_extras::session; pub use rustapi_extras::{ - MemorySessionStore, Session, SessionConfig, SessionError, SessionLayer, - SessionRecord, SessionStore, + MemorySessionStore, Session, SessionConfig, SessionError, SessionLayer, SessionRecord, + SessionStore, }; #[cfg(any(feature = "extras-session-redis", feature = "session-redis"))] @@ -339,16 +339,15 @@ pub mod prelude { pub use crate::core::Validatable; pub use crate::core::{ delete, delete_route, get, get_route, patch, patch_route, post, post_route, put, put_route, - route, serve_dir, sse_response, ApiError, AsyncValidatedJson, Body, BodyLimitLayer, - ClientIp, Created, CursorPaginate, CursorPaginated, Extension, HeaderValue, Headers, - HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthEndpointConfig, HealthStatus, - Html, IntoResponse, Json, KeepAlive, Multipart, MultipartConfig, MultipartField, - NoContent, Paginate, Paginated, Path, ProductionDefaultsConfig, Query, Redirect, Request, - RequestId, RequestIdLayer, Response, Result, Route, Router, RustApi, RustApiConfig, Sse, - SseEvent, State, StaticFile, StaticFileConfig, StatusCode, StreamBody, - StreamingMultipart, StreamingMultipartField, TracingLayer, Typed, TypedPath, - UploadedFile, ValidatedJson, WithStatus, - sse_from_iter, + route, serve_dir, sse_from_iter, sse_response, ApiError, AsyncValidatedJson, Body, + BodyLimitLayer, ClientIp, Created, CursorPaginate, CursorPaginated, Extension, HeaderValue, + Headers, HealthCheck, HealthCheckBuilder, HealthCheckResult, HealthEndpointConfig, + HealthStatus, Html, IntoResponse, Json, KeepAlive, Multipart, MultipartConfig, + MultipartField, NoContent, Paginate, Paginated, Path, ProductionDefaultsConfig, Query, + Redirect, Request, RequestId, RequestIdLayer, Response, Result, Route, Router, RustApi, + RustApiConfig, Sse, SseEvent, State, StaticFile, StaticFileConfig, StatusCode, StreamBody, + StreamingMultipart, StreamingMultipartField, TracingLayer, Typed, TypedPath, UploadedFile, + ValidatedJson, WithStatus, }; #[cfg(any(feature = "core-compression", feature = "compression"))] From bc87808a3ea134aa89796729413d423a368205a9 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 8 Mar 2026 22:31:20 +0300 Subject: [PATCH 5/7] Update app.rs --- crates/rustapi-core/src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index c4dba674..d0894616 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -1132,7 +1132,7 @@ impl RustApi { self.health_endpoint_config = Some( config .health_endpoint_config - .unwrap_or_else(crate::health::HealthEndpointConfig::default), + .unwrap_or_default(), ); } } From ab4946091184bc54befd7234ce1444b2bdde57f0 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 8 Mar 2026 22:37:47 +0300 Subject: [PATCH 6/7] ! --- crates/cargo-rustapi/src/commands/doctor.rs | 4 +- crates/cargo-rustapi/src/commands/new.rs | 6 +- crates/cargo-rustapi/src/templates/mod.rs | 12 +- crates/rustapi-core/src/app.rs | 263 ++++++++++---------- crates/rustapi-core/src/multipart.rs | 5 +- crates/rustapi-core/src/router.rs | 2 +- tmp/rustapi-core-clippy.txt | Bin 0 -> 8150 bytes 7 files changed, 145 insertions(+), 147 deletions(-) create mode 100644 tmp/rustapi-core-clippy.txt diff --git a/crates/cargo-rustapi/src/commands/doctor.rs b/crates/cargo-rustapi/src/commands/doctor.rs index a8fe2bb4..11dc0edf 100644 --- a/crates/cargo-rustapi/src/commands/doctor.rs +++ b/crates/cargo-rustapi/src/commands/doctor.rs @@ -325,9 +325,7 @@ fn build_project_checks(workspace_root: &Path) -> Result> { }); checks.push( - if (signals.production_defaults || signals.request_id) - && (signals.production_defaults || signals.tracing) - { + if signals.production_defaults || (signals.request_id && signals.tracing) { DoctorCheck::pass( "Request IDs and tracing", "Request ID and tracing signals detected", diff --git a/crates/cargo-rustapi/src/commands/new.rs b/crates/cargo-rustapi/src/commands/new.rs index 168cd186..7bfaabde 100644 --- a/crates/cargo-rustapi/src/commands/new.rs +++ b/crates/cargo-rustapi/src/commands/new.rs @@ -80,9 +80,9 @@ pub async fn new_project(mut args: NewArgs) -> Result<()> { .interact()?; match selection { - 1 => Some(ProjectPreset::ProdApi), - 2 => Some(ProjectPreset::AiApi), - 3 => Some(ProjectPreset::RealtimeApi), + 1 => Some(ProjectPreset::Production), + 2 => Some(ProjectPreset::Ai), + 3 => Some(ProjectPreset::Realtime), _ => None, } }; diff --git a/crates/cargo-rustapi/src/templates/mod.rs b/crates/cargo-rustapi/src/templates/mod.rs index da12aa47..e8c890b5 100644 --- a/crates/cargo-rustapi/src/templates/mod.rs +++ b/crates/cargo-rustapi/src/templates/mod.rs @@ -26,13 +26,13 @@ pub enum ProjectTemplate { pub enum ProjectPreset { /// Production-oriented HTTP API defaults. #[value(name = "prod-api")] - ProdApi, + Production, /// AI-friendly API defaults with TOON support. #[value(name = "ai-api")] - AiApi, + Ai, /// Realtime API defaults with WebSocket support. #[value(name = "realtime-api")] - RealtimeApi, + Realtime, } impl ProjectPreset { @@ -44,7 +44,7 @@ impl ProjectPreset { /// Recommended features that should be enabled for this preset. pub fn recommended_features(self) -> Vec { match self { - ProjectPreset::ProdApi => vec![ + ProjectPreset::Production => vec![ "extras-config", "extras-cors", "extras-rate-limit", @@ -52,13 +52,13 @@ impl ProjectPreset { "extras-structured-logging", "extras-timeout", ], - ProjectPreset::AiApi => vec![ + ProjectPreset::Ai => vec![ "extras-config", "extras-structured-logging", "extras-timeout", "protocol-toon", ], - ProjectPreset::RealtimeApi => vec![ + ProjectPreset::Realtime => vec![ "extras-cors", "extras-structured-logging", "extras-timeout", diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index d0894616..04a96478 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -1678,6 +1678,138 @@ impl Default for RustApi { } } +/// 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; @@ -2462,134 +2594,3 @@ mod tests { } } -/// 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 - } -} diff --git a/crates/rustapi-core/src/multipart.rs b/crates/rustapi-core/src/multipart.rs index 7e3a46ac..9168ed10 100644 --- a/crates/rustapi-core/src/multipart.rs +++ b/crates/rustapi-core/src/multipart.rs @@ -814,8 +814,7 @@ mod tests { #[test] fn test_parse_simple_multipart() { let boundary = "----WebKitFormBoundary"; - let body = format!( - "------WebKitFormBoundary\r\n\ + let body = "------WebKitFormBoundary\r\n\ Content-Disposition: form-data; name=\"field1\"\r\n\ \r\n\ value1\r\n\ @@ -825,7 +824,7 @@ mod tests { \r\n\ file content\r\n\ ------WebKitFormBoundary--\r\n" - ); + .to_string(); let fields = parse_multipart(&Bytes::from(body), boundary).unwrap(); assert_eq!(fields.len(), 2); diff --git a/crates/rustapi-core/src/router.rs b/crates/rustapi-core/src/router.rs index 09663f43..7f2ccbd6 100644 --- a/crates/rustapi-core/src/router.rs +++ b/crates/rustapi-core/src/router.rs @@ -1677,7 +1677,7 @@ mod property_tests { fn prop_empty_or_slashes_normalize_to_root( num_slashes in 0..10usize, ) { - let prefix: String = std::iter::repeat('/').take(num_slashes).collect(); + let prefix = "/".repeat(num_slashes); let normalized = normalize_prefix(&prefix); diff --git a/tmp/rustapi-core-clippy.txt b/tmp/rustapi-core-clippy.txt new file mode 100644 index 0000000000000000000000000000000000000000..5f5bf9d6c6e46b3156d8da1f9397030babdbe77b GIT binary patch literal 8150 zcmeI1X>S}w5Qh6R68~Z2NQo_2$JrcCjDi&G1R^8^35XAvtnJ-#vch|0cazu<@za6# zsdBrgXU`lC;m~Myrl+s!s<+;%?)~$RR@e{2@Fes@Eo|wI*LHXsiqHzbgih$GzNS{s z!zhf^exPr4)%EqQP|b_55!S<1-Pgjca9dwi!?s4-4L_(yp;jZkkJZ1Y-Y0tBQQwxz zUDftfL;aT;bsWCZ*8`Ov+H4<(kM!+s_%=Myx0XiQSGlnEv*cL|^t!8dwQyw87n0?w zN-eF4)l#C(C-yC^zh&(QTHlFk>Uz#wbzLp?)#FG{w1O53y^XAIsyozIq<*dzNY2Q0 z``yv+OY7N_oHgl>D@yiz_)sn+inu^$+qRy|7=hM?FgJ;BsBsvCz#(kF=O zsBK4YZNuB@*~rro=Q;r&v+Z3oo%x(wnrE!Gj@mVi{!{ole5&U|&Dk|cif}I+OOiqo zjn%R*`KxQZFMDCzlklD1uyd08p2i=l*H9~OYh+&|Htxt53hAe>nY!xLQ+;afbMT8# z_z3g`_s9k|_N5(4Lwm2;J6=~a+1n;nkx2`hK3Dy6-m$A$hHAU3nLzrwN+a`#oATYp znQtTuzOvb_2CuJ$S7)*$>5%-lOe$~pI8-lIpK#s?*EQ~@dNb;w^yOaa%+nMT>`S_i zOHzZG#s3VUY7-n{))kDMQkx2y?;=kuD zU0L>NlHNSkU6S-XBx2?&Tcq+G)saaYNmp=P)Xkx6IFz=iLs#&%*TI{SmB}qSvN;UN zIAdJ{mAAqbUElyb)HOO>`$Ww0#M(YJw5;pBpSAM{kHjA3%VUtg+_o%cLw%5^E!mFs zm3h0G*F7^D?3u7=TfFdbUk-$d<=% zPzb`Y4}H9h4i8n@JDt^WeIZ-Ro5t?0=74KD!ZDfWSSa;4eoF}66p9++sj{6tihHdy zYVy(a#v1T6;$hoR1Ukxoc|kVK$EA03$?LK8MT%SMMa<`(b*;!l()FWcI|yHE&Es%i z@(tw&U+78XYnv9>SqydebR#44up3!44;d|y11#{1U&1=XOy?fY5fguP)rL9A`#;kR z>}%M|+|kS{h8OVA*8G&KQem5Aq{{nw*5ta1)@+<*0rT|eJC3NyW%NkfIQz<}E*o=g zwkADPaq(&jDjWgP;=Ybpsxpz|4*dOnv0`AWVn_J8u_9-MX68j@O)VQ2^6~1~czeRr z+eCq?=Fc|<8_{cE$eMQ7 z5qIf1o&Vki55afroo8R~mxG)c=SvhBAhN0R*8Dy%#Dqt(PGQ`avvEV;7HJ`}6whUt zYEs6=!a3f(rojzNPb?r?pq$49VhdgmLP6`X?&-1N+H1@Iz1WF8F7b(Z82gI8kQL(# zlRCP-aE{47Hx~AZD<`Kd$J5EN*qKc8zvLaSi-SC3&EkVG4&%Vx< zIgRK^_O5X^cpX{(?s}meNa7rh6QQx4UF0^LFMYz(qPOy1`t9G*@AOgf-7q>lk(P*I zE7Hx1*)QQ|mUgOf5f=2Ycz>!;a6-H;x~ z#e?4_vvS`k=Q8J;Ta{NdT~lOOXv=dW-g5S12hHvWOqRKfe5&lXU=XI~Kw zK7vOY{`aZI7Z8s>jwbd8|1B1+YW-a>XnD-KH2=?L&+=+ORoa_QvL+bkOpa4fKjn4K z<39r54>+Ha4Nm@tj-FQ4yCWV=|Elr-6sMqmUdgE}dAny~oQpFfJe~O4^QON5>_Ykx literal 0 HcmV?d00001 From 9fd7e9e0a8e20b799bf26791dfad1ba3d4b855fc Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 8 Mar 2026 22:40:35 +0300 Subject: [PATCH 7/7] Update app.rs --- crates/rustapi-core/src/app.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/rustapi-core/src/app.rs b/crates/rustapi-core/src/app.rs index 04a96478..f76f2776 100644 --- a/crates/rustapi-core/src/app.rs +++ b/crates/rustapi-core/src/app.rs @@ -1129,11 +1129,8 @@ impl RustApi { } if self.health_endpoint_config.is_none() { - self.health_endpoint_config = Some( - config - .health_endpoint_config - .unwrap_or_default(), - ); + self.health_endpoint_config = + Some(config.health_endpoint_config.unwrap_or_default()); } } @@ -2593,4 +2590,3 @@ mod tests { ); } } -