diff --git a/.cursor/rules/rust-api-design.mdc b/.cursor/rules/rust-api-design.mdc new file mode 100644 index 0000000..1b8ee7c --- /dev/null +++ b/.cursor/rules/rust-api-design.mdc @@ -0,0 +1,129 @@ +--- +alwaysApply: true +--- + +# rust-api Framework Design Rules + +This project is a Rust REST API framework wrapping Axum, similar in intent to NestJS (wrapping Express). These rules encode the invariants that keep the design clean. Violations should be called out and corrected. + +## Module Responsibilities + +| Module | Responsibility | Must NOT contain | +|---|---|---| +| [controller.rs](mdc:crates/rust-api/src/controller.rs) | `Controller` trait — pure route descriptor | Auth logic, middleware, routing infrastructure | +| [middleware.rs](mdc:crates/rust-api/src/middleware.rs) | Request-time Tower layer factories | Route registration, controller logic | +| [pipeline.rs](mdc:crates/rust-api/src/pipeline.rs) | Build-time Kleisli route composition | Business logic, per-request concerns | +| [router.rs](mdc:crates/rust-api/src/router.rs) | `Router` re-export + verb enforcement helpers | Business logic | +| [di.rs](mdc:crates/rust-api/src/di.rs) | Optional DI container (advanced use) | Primary app flow (prefer direct `Arc`) | + +## Invariant 1: Controllers Are Pure + +A controller file contains **only**: +- Handler functions (`async fn`, annotated with `#[get]`, `#[post]`, etc.) +- The controller marker struct (`pub struct FooController;`) +- `mount_handlers!(...)` macro call + +Controllers must **never** contain: +- Auth logic or token validation +- `FromRequestParts` implementations for security enforcement +- Imports of `axum` directly (use `rust_api::prelude::*`) +- Knowledge of `Router`, `RouteSet`, or any DI container + +```rust +// CORRECT — pure controller +#[get("/users")] +pub async fn list_users(State(svc): State>) -> Json> { + Json(svc.list()) +} +pub struct UserController; +mount_handlers!(UserController, UserService, [(__list_users_route, list_users)]); + +// WRONG — auth leaking into controller +pub async fn list_users(_auth: ApiToken, State(svc): ...) -> ... { ... } +``` + +## Invariant 2: Auth Is a Tower Layer on a Route Group + +Authentication/authorization is a **cross-cutting concern**. It belongs in `middleware.rs` as a `Router -> Router` transform applied via `.map()` in the pipeline — **never** as an extractor in a handler signature for security enforcement. + +```rust +// CORRECT — auth applied as a layer to the route group +RouterPipeline::new() + .mount_guarded::(admin_svc, || { /* config check */ }) + .map(require_bearer(admin_key)) // layer applied after mount + .build()? + +// WRONG — auth baked into the handler +async fn admin_status(_: AdminToken, State(svc): ...) -> ... { ... } +``` + +This mirrors NestJS Guards and Next.js middleware: auth is applied at the routing layer, not inside handler logic. + +## Invariant 3: No Direct `axum` Dependency in User Crates + +`axum` is an **implementation detail** of `rust-api`. Client crates (examples, user apps) must never add `axum` as a direct dependency. All surface types needed to write handlers, services, and middleware are re-exported from `rust_api::prelude::*`. + +```toml +# CORRECT — Cargo.toml +[dependencies] +rust-api = { path = "../../crates/rust-api" } + +# WRONG — exposes implementation detail +[dependencies] +axum = "0.8" +rust-api = { path = "../../crates/rust-api" } +``` + +If a type is missing from the prelude, add it to `crates/rust-api/src/lib.rs` — do not add `axum` to the user crate. + +## Invariant 4: `mount_guarded` vs `mount_if` + +| Method | Semantics | Server behaviour on failure | +|---|---|---| +| `mount_if(condition, svc)` | Optional feature toggle | Silently skips, server starts normally | +| `mount_guarded(svc, guard)` | Hard requirement — guard must pass | Server **refuses to start** (`build()` returns `Err`) | + +Use `mount_guarded` when the feature is **required** (e.g., admin routes need a configured key to be safe). Use `mount_if` when the feature is **optional** (e.g., metrics endpoint toggled by env var). + +## Invariant 5: Services Hold Business Logic Only + +Services must **never** contain: +- Auth tokens or API keys +- `&mut self` methods (use `Atomic*` primitives or channels for mutable state) +- `Mutex` on the service struct itself +- Any concept of HTTP (headers, status codes, requests) + +```rust +// CORRECT — immutable service, atomic counter +pub struct EchoService { count: AtomicU64 } +impl EchoService { pub fn echo(&self, msg: &str) -> EchoResponse { ... } } + +// WRONG — service holds auth secret +pub struct AdminService { pub(crate) api_key: String } +``` + +## Invariant 6: Primary DI Is `Arc`, Not the Container + +The `Container` (in `di.rs`) is an **optional** advanced utility. The primary DI mechanism is direct `Arc` construction and passing to `pipeline.mount::(svc)`. The `Container` is not in the prelude and should not appear in typical application code. + +```rust +// CORRECT +let svc = Arc::new(MyService::new()); +RouterPipeline::new().mount::(svc).build()? + +// AVOID for simple cases +let mut container = Container::new(); +container.register::(...); +``` + +## Invariant 7: `require_bearer` Usage Pattern + +`require_bearer(token)` returns a `Fn(Router<()>) -> Router<()>` — pass it directly to `.map()`, not to `.layer()`: + +```rust +// CORRECT +.map(require_bearer(admin_key)) + +// WRONG — require_bearer is already a Router->Router transform +.map(|r| r.layer(require_bearer(admin_key))) +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..52822bf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# Multi-stage build — final image contains only the binary, not the Rust toolchain. +# +# Build: docker build -t rust-api . +# Run: docker run -p 3000:3000 -e ADMIN_API_KEY=secret rust-api + +# --------------------------------------------------------------------------- +# Stage 1: builder +# --------------------------------------------------------------------------- +FROM rust:slim AS builder + +WORKDIR /app + +# Copy manifests first so dependency layers are cached separately from source. +COPY Cargo.toml Cargo.lock ./ +COPY crates/rust-api/Cargo.toml crates/rust-api/Cargo.toml +COPY crates/rust-api-macros/Cargo.toml crates/rust-api-macros/Cargo.toml +COPY examples/basic-api/Cargo.toml examples/basic-api/Cargo.toml + +# Stub out source so Cargo can resolve and cache dependencies without full source. +RUN mkdir -p crates/rust-api/src \ + crates/rust-api-macros/src \ + examples/basic-api/src \ + && echo "fn main() {}" > examples/basic-api/src/main.rs \ + && echo "" > crates/rust-api/src/lib.rs \ + && echo "" > crates/rust-api-macros/src/lib.rs + +RUN cargo build --release -p basic-api 2>/dev/null || true + +# Now copy the real source and build for real. +COPY crates/ crates/ +COPY examples/ examples/ + +# Touch all real source files to bust the cached stub build artifacts. +# Using find so new crates are automatically included without Dockerfile changes. +RUN find crates/ examples/ -name "*.rs" -exec touch {} + \ + && cargo build --release -p basic-api + +# --------------------------------------------------------------------------- +# Stage 2: runtime +# --------------------------------------------------------------------------- +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/basic-api /usr/local/bin/basic-api + +EXPOSE 3000 + +ENV RUST_LOG=info + +CMD ["basic-api"] diff --git a/crates/rust-api-macros/src/route.rs b/crates/rust-api-macros/src/route.rs index bd9995c..4b92a08 100644 --- a/crates/rust-api-macros/src/route.rs +++ b/crates/rust-api-macros/src/route.rs @@ -66,13 +66,16 @@ impl Parse for RouteArgs { /// async fn get_user(Path(id): Path) -> Json { ... } /// ``` /// -/// Into the original function plus a route path constant: +/// Into the original function plus a route info tuple constant: /// ```ignore /// async fn get_user(Path(id): Path) -> Json { ... } -/// const __get_user_route: &str = "/users/{id}"; +/// const __get_user_route: (&'static str, &'static str) = ("/users/{id}", "GET"); /// ``` +/// +/// The tuple encodes both path and HTTP method, making the annotation a +/// binding contract — the verb from the annotation is the sole authority. pub fn expand_route_macro( - _method: HttpMethod, + method: HttpMethod, args: TokenStream, input: TokenStream, ) -> TokenStream { @@ -85,16 +88,21 @@ pub fn expand_route_macro( let func_name = &func.sig.ident; let func_vis = &func.vis; - // generate route registration helper + // generate route registration helper name let route_helper_name = syn::Ident::new(&format!("__{}_route", func_name), func_name.span()); + // encode the HTTP method as a string literal + let method_str = method.as_str(); + let expanded = quote! { //original handler function #func - //route path constant - stores just the path for registration + // Route info tuple: (path, HTTP method). + // The method comes from the macro annotation — this is the enforcement contract. + // Use with Router::api_route() or RouterPipeline::route() to register correctly. #[allow(non_upper_case_globals)] - #func_vis const #route_helper_name: &str = #path; + #func_vis const #route_helper_name: (&'static str, &'static str) = (#path, #method_str); }; TokenStream::from(expanded) diff --git a/crates/rust-api/Cargo.toml b/crates/rust-api/Cargo.toml index f82d47a..5ee93bb 100644 --- a/crates/rust-api/Cargo.toml +++ b/crates/rust-api/Cargo.toml @@ -29,3 +29,5 @@ thiserror = { workspace = true } [dev-dependencies] tokio-test = "0.4" tracing-subscriber = { workspace = true } +tower = { workspace = true, features = ["util"] } +http-body-util = "0.1" diff --git a/crates/rust-api/src/controller.rs b/crates/rust-api/src/controller.rs new file mode 100644 index 0000000..696ca60 --- /dev/null +++ b/crates/rust-api/src/controller.rs @@ -0,0 +1,68 @@ +//! Controller trait — pure descriptor for composable route groups. +//! +//! A `Controller` is a zero-knowledge marker type. It knows only two things: +//! 1. `type State` — the service it needs (`Arc` is resolved and passed in) +//! 2. `fn mount` — a Kleisli arrow `Arc -> (Router -> Result)` +//! +//! Controllers have **no dependency on `Router`, `RouteSet`, or any DI container**. +//! The `mount_handlers!` macro generates the `mount` implementation from a +//! simple list of `(route_constant, handler_fn)` pairs. Users only write handlers. +//! +//! # Kleisli Composition +//! +//! `mount` returns `impl FnOnce(Router<()>) -> Result>` — a Kleisli +//! arrow in the `Result` monad. The `RouterPipeline` composes these arrows with +//! `and_then` (`>>=`), threading the router through each controller in sequence. +//! A failed arrow short-circuits the rest. +//! +//! # Immutability Contract +//! +//! State is passed as `Arc` — shared, immutable after construction. Services +//! must not expose `&mut self` methods. All state mutation goes through `Atomic*` +//! primitives or channels (never `Mutex` on the service struct). +//! +//! # Example +//! +//! ```ignore +//! pub struct HealthController; +//! +//! #[get("/health")] +//! pub async fn health_check(State(svc): State>) -> Json { +//! Json(svc.health_check()) +//! } +//! +//! // mount_handlers! generates the full Controller impl — user never writes Router. +//! mount_handlers!(HealthController, HealthService, [ +//! (__health_check_route, health_check), +//! ]); +//! ``` + +use std::sync::Arc; + +use crate::{error::Result, router::Router}; + +/// A pure descriptor for a group of HTTP routes sharing a common service dependency. +/// +/// Do not implement this trait manually. Use the [`mount_handlers!`] macro, +/// which generates the correct Kleisli arrow from your handler list. +/// +/// The trait's only concern is: given `Arc`, produce a Kleisli arrow +/// that registers this controller's routes into a `Router<()>`. +pub trait Controller: Sized + 'static { + /// The service this controller depends on. + /// + /// The framework wraps it in `Arc` — `Clone` is not required on the + /// service itself. Use `Atomic*` primitives or channels for any state that + /// changes after construction. + type State: Send + Sync + 'static; + + /// Returns the Kleisli arrow for this controller. + /// + /// Signature: `Arc -> (Router<()> -> Result>)` + /// + /// Generated by `mount_handlers!`. The returned closure: + /// 1. Builds a scoped `Router>` with this controller's routes. + /// 2. Provides state via `.with_state(state)` → `Router<()>`. + /// 3. Merges into the outer `router` and returns `Ok(merged)`. + fn mount(state: Arc) -> impl FnOnce(Router<()>) -> Result>; +} diff --git a/crates/rust-api/src/di.rs b/crates/rust-api/src/di.rs index 2215737..7c79f46 100644 --- a/crates/rust-api/src/di.rs +++ b/crates/rust-api/src/di.rs @@ -1,15 +1,55 @@ -//! Dependency Injection Container +//! Dependency Injection Container (optional utility) //! -//! A simple, type-safe DI container that stores services as Arc-wrapped trait -//! objects. Services can be registered and retrieved by type, with automatic -//! Arc wrapping. +//! **Primary DI in RustAPI:** pass `Arc` directly to +//! `RouterPipeline::mount::(Arc::new(MyService::new()))`. +//! No container is needed for the standard use case. +//! +//! This module provides an optional [`Container`] — a type-safe service +//! registry useful for larger applications with dynamic or plugin-driven +//! service graphs, where you want late-binding resolution by type. +//! +//! # When to Use +//! +//! - You have a plugin system that registers services dynamically +//! - You want to swap implementations at runtime (e.g. test doubles via trait objects) +//! - You have many services and prefer centralized registration over explicit wiring +//! +//! # When NOT to Use +//! +//! For most APIs: construct `Arc` directly in `main` and pass it to +//! `pipeline.mount()`. This is explicit, compile-time-checked, and requires no +//! type-map machinery. +//! +//! # Immutability Discipline +//! +//! Services registered in the container are stored as `Arc` and shared +//! read-only across the application. Service types should be immutable after +//! construction — model state changes via `Atomic*` primitives or channels, +//! not `Mutex` fields. The framework enforces this by only providing +//! shared (`Arc`) references, never exclusive (`Arc>`) ones. +//! +//! # Async Initialization +//! +//! Use [`Container::register_async_factory`] for services that require async +//! initialization (database connections, config loading, etc.). The async +//! effect is contained at container-construction time; all subsequent +//! framework operations remain synchronous. +//! +//! Alternatively, without a container: +//! ```ignore +//! let svc = Arc::new(MyService::connect("postgres://...").await?); +//! RouterPipeline::new().mount::(svc).build()? +//! ``` use std::{ any::{Any, TypeId}, collections::HashMap, + future::Future, sync::Arc, }; +use crate::error::Result; + /// Trait that all injectable services must implement pub trait Injectable: Send + Sync + 'static {} @@ -84,6 +124,36 @@ impl Container { self.register(service); } + /// Register a service from an **async** constructor function. + /// + /// Use this for services that require async initialization — database + /// connections, config loading from remote sources, etc. The async effect + /// is contained here; once registered, the service is a plain `Arc` + /// and all subsequent framework operations remain synchronous. + /// + /// Returns `Err` if the factory future resolves to an error. + /// + /// # Example + /// + /// ```ignore + /// container + /// .register_async_factory(|| async { + /// let db = Database::connect("postgres://localhost/mydb").await?; + /// Ok(DbService::new(db)) + /// }) + /// .await?; + /// ``` + pub async fn register_async_factory(&mut self, factory: F) -> Result<()> + where + T: Injectable, + F: FnOnce() -> Fut, + Fut: Future>, + { + let service = factory().await?; + self.register(Arc::new(service)); + Ok(()) + } + // create a service instance from a factory function fn create_service(&self, factory: F) -> Arc where diff --git a/crates/rust-api/src/error.rs b/crates/rust-api/src/error.rs index f49aabb..8a06bcd 100644 --- a/crates/rust-api/src/error.rs +++ b/crates/rust-api/src/error.rs @@ -16,6 +16,10 @@ pub enum Error { #[error("Service registration failed: {0}")] RegistrationError(String), + /// Async service factory failed during container population + #[error("Async service initialization failed: {0}")] + ContainerError(String), + /// HTTP server error #[error("HTTP server error: {0}")] ServerError(String), @@ -40,6 +44,11 @@ impl Error { Self::RegistrationError(msg.into()) } + /// Create a ContainerError (async factory failure) + pub fn container_error(msg: impl Into) -> Self { + Self::ContainerError(msg.into()) + } + /// Create a ServerError pub fn server_error(msg: impl Into) -> Self { Self::ServerError(msg.into()) diff --git a/crates/rust-api/src/lib.rs b/crates/rust-api/src/lib.rs index c94bf13..c6fc4ce 100644 --- a/crates/rust-api/src/lib.rs +++ b/crates/rust-api/src/lib.rs @@ -7,64 +7,75 @@ //! # Features //! //! - **Route Macros**: Define endpoints with `#[get]`, `#[post]`, etc. -//! - **Dependency Injection**: Type-safe DI container for services -//! - **Type-Driven**: Leverage Rust's type system for validation and docs -//! - **Zero-Cost**: Built on Axum and Tokio for production performance +//! The HTTP verb from the annotation is a binding contract — enforced at +//! registration time via the `mount_handlers!` macro. +//! - **Kleisli Pipeline**: `RouterPipeline` composes `Controller` Kleisli arrows +//! with `and_then` (`>>=`), short-circuiting on any error. +//! - **Direct DI**: Pass `Arc` directly to `pipeline.mount::(svc)`. +//! No type-map registry required for the primary use case. +//! - **Type-Driven**: Leverage Rust's type system for validation and docs. +//! - **Zero-Cost**: Built on Axum and Tokio for production performance. //! //! # Quick Start //! //! ```ignore //! use rust_api::prelude::*; //! -//! #[get("/users/{id}")] -//! async fn get_user(Path(id): Path) -> Json { -//! // handler code +//! pub struct UserController; +//! +//! #[get("/users")] +//! pub async fn list_users(State(svc): State>) -> Json> { +//! Json(svc.list()) //! } //! +//! mount_handlers!(UserController, UserService, [(__list_users_route, list_users)]); +//! //! #[tokio::main] //! async fn main() { -//! let app = Router::new() -//! .route(__get_user_route, routing::get(get_user)); +//! let svc = Arc::new(UserService::new()); //! -//! RustAPI::new(app) -//! .port(3000) -//! .serve() -//! .await +//! let app = RouterPipeline::new() +//! .mount::(svc) +//! .map(|r| r.layer(TraceLayer::new_for_http())) +//! .build() //! .unwrap(); +//! +//! RustAPI::new(app).port(3000).serve().await.unwrap(); //! } //! ``` -//! -//! # Examples -//! -//! See the `examples/` directory for complete working examples: -//! -//! - `basic-api`: Complete example with controllers, services, and DI // Core modules pub mod app; +pub mod controller; pub mod di; pub mod error; +pub mod middleware; +pub mod pipeline; pub mod router; pub mod server; // Re-export core types pub use app::App; +pub use controller::Controller; pub use di::{Container, Injectable}; pub use error::{Error, Result}; -pub use router::{Router, RouterExt}; +pub use middleware::{guard, require_bearer}; +pub use pipeline::{RouterPipeline, RouterTransform}; +pub use router::{method_filter_from_str, ApiRoute, Router, RouterExt}; pub use server::RustAPI; -// Re-export routing methods from Axum -// These are used to define route handlers (get, post, put, delete, etc.) +// Re-export routing methods from Axum (available for advanced use) pub mod routing { pub use axum::routing::*; } -// Re-export common middleware layers -// Re-export commonly used axum types +// Re-export commonly used axum types. +// Clients should never need to add `axum` as a direct dependency — all +// surface types required to write handlers, extractors, and middleware +// are available through this crate. pub use axum::{ - extract::{Path, Query, State}, - http::StatusCode, + extract::{FromRequestParts, Path, Query, State}, + http::{header, request::Parts, StatusCode}, response::{IntoResponse, Response}, Json, }; @@ -72,47 +83,142 @@ pub use axum::{ pub use rust_api_macros::{delete, get, patch, post, put}; // Re-export serde for user convenience pub use serde::{Deserialize, Serialize}; +pub use std::sync::Arc; pub use tower_http::{cors::CorsLayer, trace::TraceLayer}; -/// Prelude module for convenient imports +/// Generate a [`Controller`] implementation for a type from a handler list. +/// +/// This macro produces the Kleisli arrow `Arc -> (Router -> Result)` +/// that the `RouterPipeline::mount` method threads through the pipeline. +/// +/// Controllers using this macro have **zero dependency on `Router`, `RouteSet`, +/// or any DI container** in their source files. +/// +/// # Syntax +/// +/// ```ignore +/// mount_handlers!(ControllerType, ServiceType, [ +/// (__route_constant_1, handler_fn_1), +/// (__route_constant_2, handler_fn_2), +/// ]); +/// ``` +/// +/// Where each `__route_constant` is the tuple `(&'static str, &'static str)` +/// generated by a `#[get]`/`#[post]`/etc. annotation on the handler function. +/// +/// # Example +/// +/// ```ignore +/// pub struct HealthController; +/// +/// #[get("/health")] +/// pub async fn health_check(State(svc): State>) -> Json { +/// Json(svc.health_check()) +/// } +/// +/// mount_handlers!(HealthController, HealthService, [ +/// (__health_check_route, health_check), +/// ]); +/// ``` +/// +/// Then in `main.rs`: +/// +/// ```ignore +/// RouterPipeline::new() +/// .mount::(Arc::new(HealthService::new())) +/// .build()? +/// ``` +#[macro_export] +macro_rules! mount_handlers { + // Entry: ControllerType, StateType, [ (route_const, handler), ... ] + ($controller:ty, $state:ty, [ $( ($route:expr, $handler:expr) ),* $(,)? ]) => { + impl $crate::controller::Controller for $controller { + type State = $state; + + fn mount( + state: ::std::sync::Arc<$state>, + ) -> impl ::std::ops::FnOnce( + $crate::router::Router<()>, + ) -> $crate::error::Result<$crate::router::Router<()>> { + move |router| { + // Build a scoped router> with one .route() per handler. + // The HTTP verb is read from the route constant — it is the + // annotation-enforced contract and cannot be overridden here. + let scoped: $crate::router::Router<::std::sync::Arc<$state>> = + $crate::router::Router::new() + $( + .route( + $route.0, + $crate::routing::on( + $crate::router::method_filter_from_str($route.1), + $handler, + ), + ) + )*; + // Provide state (Router> -> Router<()>) and merge. + Ok(router.merge(scoped.with_state(state))) + } + } + } + }; +} + +/// Prelude module for convenient imports. /// /// Import everything you need with: /// ```ignore /// use rust_api::prelude::*; /// ``` +/// +/// # DI Note +/// +/// [`Container`](crate::di::Container) is intentionally **not** in the prelude. +/// The primary DI mechanism is direct `Arc` passing to +/// `RouterPipeline::mount`. Import `Container` explicitly if you need a +/// registry for large apps: `use rust_api::di::Container;` pub mod prelude { - // Also re-export tokio for async runtime pub use tokio; + // Re-export the mount_handlers! macro so `use rust_api::prelude::*` brings it in. + pub use crate::mount_handlers; + pub use super::{ - delete, // Macros + delete, get, + guard, + header, patch, - post, put, + require_bearer, router, - routing, - + // Router + ApiRoute, App, - // Core - Container, + // Core types + Arc, + // Controller trait — visible for doc purposes; mount_handlers! generates impls. + Controller, // Middleware CorsLayer, Deserialize, Error, - Injectable, + // Axum — all surface types needed to write handlers and custom extractors. + // Clients must never add `axum` as a direct dependency. + FromRequestParts, IntoResponse, - // Axum Json, + Parts, Path, Query, Response, - Result, Router, RouterExt, + // Pipeline + RouterPipeline, + RouterTransform, RustAPI, // Serde Serialize, diff --git a/crates/rust-api/src/middleware.rs b/crates/rust-api/src/middleware.rs new file mode 100644 index 0000000..ef140d7 --- /dev/null +++ b/crates/rust-api/src/middleware.rs @@ -0,0 +1,371 @@ +//! Request-lifecycle middleware utilities and Tower layer factories. +//! +//! This module provides utilities that operate at **request time**, not at +//! route registration time. These are Tower layer factories — they produce +//! middleware that wraps individual routes or entire routers. +//! +//! # Module Boundaries +//! +//! - `pipeline.rs` — build-time: compose routes into a `Router` +//! - `controller.rs` — build-time: declare which handlers belong to a controller +//! - `middleware.rs` — request-time: inspect/modify requests and responses +//! +//! # Protected Route Groups +//! +//! Auth is a **cross-cutting concern** — it belongs here as a router transform, +//! not inside a controller handler. Apply it to a route group via `.map()`: +//! +//! ```ignore +//! RouterPipeline::new() +//! .mount_guarded::(admin_svc, || { /* config check */ }) +//! .map(require_bearer(admin_key)) +//! ``` +//! +//! Or scoped to just a sub-group: +//! +//! ```ignore +//! RouterPipeline::new() +//! .group("/admin", |g| g +//! .mount::(admin_svc) +//! .map(require_bearer(admin_key)) // only admin routes are protected +//! ) +//! ``` + +use axum::{body::Body, http::Request, middleware::Next, response::IntoResponse, Router}; + +// --------------------------------------------------------------------------- +// require_bearer +// --------------------------------------------------------------------------- + +/// Returns a `Router -> Router` transform that enforces `Authorization: Bearer +/// ` on every request passing through the router it is applied to. +/// +/// Returns `401 Unauthorized` if the header is absent, malformed, or if the +/// token does not match `expected` (compared in **constant time** to prevent +/// timing oracles). +/// +/// # Usage +/// +/// Pass directly to `.map()` — the function signature matches `.map()`'s +/// expected `Fn(Router<()>) -> Router<()>`: +/// +/// ```ignore +/// use rust_api::prelude::*; +/// +/// RouterPipeline::new() +/// .mount_guarded::(admin_svc, || { /* config check */ }) +/// .map(require_bearer(admin_key)) +/// .build()? +/// ``` +pub fn require_bearer( + expected: impl Into, +) -> impl Fn(Router<()>) -> Router<()> + Clone + Send + 'static { + let expected = expected.into(); + move |router: Router<()>| { + let expected = expected.clone(); + router.layer(axum::middleware::from_fn( + move |req: Request, next: Next| { + let expected = expected.clone(); + async move { + let authorized = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|token| constant_time_eq(token.as_bytes(), expected.as_bytes())) + .unwrap_or(false); + + if authorized { + next.run(req).await + } else { + axum::http::StatusCode::UNAUTHORIZED.into_response() + } + } + }, + )) + } +} + +// --------------------------------------------------------------------------- +// guard +// --------------------------------------------------------------------------- + +/// Returns a `Router -> Router` transform that guards every request with a +/// predicate. +/// +/// Returns `403 Forbidden` if `guard_fn(&request)` returns `false`. The +/// predicate runs before any extractors, so it has access to headers, URI, +/// and method. +/// +/// For **authentication**, prefer [`require_bearer`] — it handles the +/// `Authorization: Bearer` protocol correctly. `guard` is suited for +/// non-auth predicates (e.g., IP allowlists, feature flags, method +/// restrictions). +/// +/// # Usage +/// +/// Pass directly to `.map()` on the pipeline: +/// +/// ```ignore +/// use rust_api::prelude::*; +/// +/// RouterPipeline::new() +/// .mount::(svc) +/// .map(guard(|req| is_allowed_ip(req))) +/// .build()? +/// ``` +pub fn guard(guard_fn: G) -> impl Fn(Router<()>) -> Router<()> + Clone + Send + 'static +where + G: Fn(&Request) -> bool + Clone + Send + Sync + 'static, +{ + move |router: Router<()>| { + let guard_fn = guard_fn.clone(); + router.layer(axum::middleware::from_fn( + move |req: Request, next: Next| { + let guard_fn = guard_fn.clone(); + async move { + if guard_fn(&req) { + next.run(req).await + } else { + axum::http::StatusCode::FORBIDDEN.into_response() + } + } + }, + )) + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Constant-time byte-slice equality — prevents timing oracle attacks. +/// +/// XORs every byte of both slices (zero-padded to the longer length) and +/// accumulates the differences. No early exit: a short token cannot +/// short-circuit the comparison. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + let len = a.len().max(b.len()); + let mut diff: u8 = 0; + for i in 0..len { + let ab = a.get(i).copied().unwrap_or(0); + let bb = b.get(i).copied().unwrap_or(0); + diff |= ab ^ bb; + } + diff == 0 +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Request, routing::get, Router}; + use http_body_util::BodyExt; + use tower::ServiceExt; + + // ----------------------------------------------------------------------- + // constant_time_eq + // ----------------------------------------------------------------------- + + #[test] + fn ct_eq_identical_slices() { + assert!(constant_time_eq(b"secret", b"secret")); + } + + #[test] + fn ct_eq_different_slices() { + assert!(!constant_time_eq(b"secret", b"wrong!")); + } + + #[test] + fn ct_eq_empty_slices() { + assert!(constant_time_eq(b"", b"")); + } + + #[test] + fn ct_eq_different_lengths_short_a() { + assert!(!constant_time_eq(b"abc", b"abcd")); + } + + #[test] + fn ct_eq_different_lengths_short_b() { + assert!(!constant_time_eq(b"abcd", b"abc")); + } + + #[test] + fn ct_eq_empty_vs_nonempty() { + assert!(!constant_time_eq(b"", b"x")); + } + + // ----------------------------------------------------------------------- + // require_bearer + // ----------------------------------------------------------------------- + + fn bearer_router() -> Router<()> { + let inner = Router::new().route("/protected", get(|| async { "ok" })); + require_bearer("correct-token")(inner) + } + + #[tokio::test] + async fn bearer_accepts_correct_token() { + let app = bearer_router(); + let response = app + .oneshot( + Request::builder() + .uri("/protected") + .header("Authorization", "Bearer correct-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), 200); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"ok"); + } + + #[tokio::test] + async fn bearer_rejects_wrong_token() { + let app = bearer_router(); + let response = app + .oneshot( + Request::builder() + .uri("/protected") + .header("Authorization", "Bearer wrong-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), 401); + } + + #[tokio::test] + async fn bearer_rejects_missing_header() { + let app = bearer_router(); + let response = app + .oneshot( + Request::builder() + .uri("/protected") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), 401); + } + + #[tokio::test] + async fn bearer_rejects_malformed_header() { + let app = bearer_router(); + let response = app + .oneshot( + Request::builder() + .uri("/protected") + .header("Authorization", "correct-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), 401); + } + + // ----------------------------------------------------------------------- + // guard + // ----------------------------------------------------------------------- + + fn guard_router( + predicate: impl Fn(&Request) -> bool + Clone + Send + Sync + 'static, + ) -> Router<()> { + let inner = Router::new().route("/guarded", get(|| async { "ok" })); + guard(predicate)(inner) + } + + #[tokio::test] + async fn guard_allows_request_when_predicate_is_true() { + let app = guard_router(|_req| true); + let response = app + .oneshot( + Request::builder() + .uri("/guarded") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), 200); + } + + #[tokio::test] + async fn guard_blocks_request_with_403_when_predicate_is_false() { + let app = guard_router(|_req| false); + let response = app + .oneshot( + Request::builder() + .uri("/guarded") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), 403); + } + + #[tokio::test] + async fn guard_predicate_receives_live_request_headers() { + let app = guard_router(|req| req.headers().contains_key("x-allowed")); + + // without header → 403 + let blocked = app + .clone() + .oneshot( + Request::builder() + .uri("/guarded") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(blocked.status(), 403); + + // with header → 200 + let allowed = app + .oneshot( + Request::builder() + .uri("/guarded") + .header("x-allowed", "yes") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(allowed.status(), 200); + } + + #[tokio::test] + async fn guard_predicate_receives_live_request_uri() { + // predicate inspects the URI path + let app = guard_router(|req| req.uri().path().starts_with("/guarded")); + + let response = app + .oneshot( + Request::builder() + .uri("/guarded") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), 200); + } +} diff --git a/crates/rust-api/src/pipeline.rs b/crates/rust-api/src/pipeline.rs new file mode 100644 index 0000000..1dc7565 --- /dev/null +++ b/crates/rust-api/src/pipeline.rs @@ -0,0 +1,411 @@ +//! Monadic router pipeline for composable, error-propagating route registration. +//! +//! [`RouterPipeline`] wraps `Result>` and provides a fluent builder +//! API where every step is `Result::and_then` (`>>=`). A failed step +//! short-circuits all subsequent steps. The error surfaces at `.build()`. +//! +//! # The Kleisli Model +//! +//! Each `mount::(state)` call creates a Kleisli arrow +//! `Router<()> -> Result>` from the controller's `mount` fn and +//! threads it through the pipeline via `and_then`. The pipeline IS the +//! Kleisli compositor — controllers are pure arrows, they don't compose +//! themselves. +//! +//! # Algebraic Operations +//! +//! | Method | Concept | Description | +//! |---|---|---| +//! | `map(f)` | Functor (`fmap`) | Infallible `Router -> Router` transform | +//! | `and_then(f)` | Monad bind (`>>=`) | Fallible `Router -> Result` | +//! | `mount::(state)` | Kleisli bind | Thread router through a `Controller` arrow | +//! | `mount_if::(bool, state)` | Conditional bind | Mount only when condition is `true` | +//! | `mount_guarded::(state, g)` | Guarded bind | Mount only when guard `g()` succeeds | +//! | `fold(steps)` | Catamorphism | Apply a dynamic list of fallible steps | +//! | `layer_all(transforms)` | `fold` over transforms | Apply a list of `Router -> Router` fns | +//! | `group(prefix, f)` | Scoped functor | Sub-pipeline with path prefix applied | +//! | `route(info, handler)` | Route registration | Stateless route via route info tuple | +//! | `build()` | Interpreter / run | Consume pipeline, surface `Result>` | +//! +//! # Example +//! +//! ```ignore +//! let health_svc = Arc::new(HealthService::new()); +//! let echo_svc = Arc::new(EchoService::new()); +//! +//! let app = RouterPipeline::new() +//! .mount::(health_svc) +//! .mount_if::(config.enable_echo, echo_svc) +//! .route(__root_route, root_handler) +//! .map(|r| r.layer(TraceLayer::new_for_http())) +//! .map(|r| r.layer(CorsLayer::permissive())) +//! .build()?; +//! ``` + +use std::sync::Arc; + +use crate::{ + controller::Controller, + error::Result, + router::{ApiRoute, Router}, +}; + +/// A boxed, infallible router transformation. +/// +/// Used with [`RouterPipeline::layer_all`] to apply a dynamic collection of +/// transforms (e.g., middleware layers) to the pipeline. +pub type RouterTransform = Box) -> Router<()>>; + +/// Monadic router builder that propagates errors through the pipeline via +/// Kleisli composition. +/// +/// Wraps `Result>`. Each step is `Result::and_then` — any error +/// short-circuits the rest of the chain. Call [`build`](RouterPipeline::build) +/// at the end to surface the final `Result>`. +/// +/// See [module-level docs](self) for the full operation table. +pub struct RouterPipeline(Result>); + +impl RouterPipeline { + /// Start a new pipeline with an empty `Router<()>`. + pub fn new() -> Self { + Self(Ok(crate::router::build())) + } + + // ----------------------------------------------------------------------- + // Core operations + // ----------------------------------------------------------------------- + + /// Kleisli bind: thread the router through a [`Controller`]'s Kleisli arrow. + /// + /// Calls `C::mount(state)` to obtain the arrow, then threads it via + /// `and_then`. The controller's routes are merged into the pipeline's + /// router. Short-circuits if any previous step failed. + /// + /// The controller has **no knowledge of routing infrastructure** — it only + /// provides the Kleisli arrow. The pipeline is the sole compositor. + pub fn mount(self, state: Arc) -> Self { + Self(self.0.and_then(C::mount(state))) + } + + /// Functor map (`fmap`): apply an infallible `Router -> Router` transform. + /// + /// The most common use is adding a middleware layer: + /// ```ignore + /// pipeline.map(|r| r.layer(TraceLayer::new_for_http())) + /// ``` + pub fn map(self, f: F) -> Self + where + F: FnOnce(Router<()>) -> Router<()>, + { + Self(self.0.map(f)) + } + + /// Monad bind (`>>=`): apply a fallible `Router -> Result` transform. + /// + /// Short-circuits on any previous error. Use for transforms that can fail. + pub fn and_then(self, f: F) -> Self + where + F: FnOnce(Router<()>) -> Result>, + { + Self(self.0.and_then(f)) + } + + /// Register a stateless route (no service state) using a route info tuple. + /// + /// The `route_info` tuple is produced by a route macro annotation: + /// `__root_route` is `("/", "GET")` when annotated `#[get("/")]`. + /// The HTTP verb is enforced by [`ApiRoute::api_route`]. + pub fn route(self, route_info: (&'static str, &'static str), handler: H) -> Self + where + H: axum::handler::Handler, + T: 'static, + { + self.map(|r| r.api_route(route_info, handler)) + } + + /// Terminate the pipeline and return the built `Router<()>`. + /// + /// Use `?` at the call site to propagate any error that occurred during + /// pipeline construction: + /// ```ignore + /// let app = RouterPipeline::new() + /// .mount::(Arc::new(HealthService::new())) + /// .build()?; + /// ``` + pub fn build(self) -> Result> { + self.0 + } + + // ----------------------------------------------------------------------- + // Conditional and guarded mounting + // ----------------------------------------------------------------------- + + /// Conditional mount: mount a [`Controller`] only when `condition` is `true`. + /// + /// When `false`, the pipeline passes through unchanged — no error produced. + /// The `state` value is moved into the mount call when `condition` is `true`, + /// or dropped when `false`. + /// + /// ```ignore + /// RouterPipeline::new() + /// .mount::(health_svc) + /// .mount_if::(config.enable_metrics, metrics_svc) + /// .mount_if::(env.is_dev(), admin_svc) + /// .build()? + /// ``` + pub fn mount_if(self, condition: bool, state: Arc) -> Self { + if condition { + self.mount::(state) + } else { + // identity — drop state, pass the pipeline through unchanged + self + } + } + + /// Guarded mount: mount a [`Controller`] only when `guard()` returns `Ok(())`. + /// + /// The guard is a fallible predicate evaluated before the controller's + /// Kleisli arrow runs. A guard error short-circuits the pipeline the same + /// way as a failed `mount`. + /// + /// Use this for runtime checks (required config, capability flags, etc.): + /// + /// ```ignore + /// RouterPipeline::new() + /// .mount_guarded::(admin_svc, || { + /// if config.admin_secret.is_empty() { + /// Err(Error::other("admin_secret must be set")) + /// } else { + /// Ok(()) + /// } + /// }) + /// .build()? + /// ``` + pub fn mount_guarded(self, state: Arc, guard: G) -> Self + where + G: FnOnce() -> Result<()>, + { + Self(self.0.and_then(|router| { + guard()?; + C::mount(state)(router) + })) + } + + // ----------------------------------------------------------------------- + // Collection operations + // ----------------------------------------------------------------------- + + /// Catamorphism (fold): apply a dynamic, ordered collection of fallible + /// `Router -> Result` steps, left-to-right. + /// + /// Short-circuits on the first error. Replaces imperative `for` loops + /// when the set of pipeline steps is known only at runtime. + /// + /// ```ignore + /// let steps: Vec) -> Result>>> = vec![ + /// Box::new(HealthController::mount(health_svc)), + /// Box::new(EchoController::mount(echo_svc)), + /// ]; + /// + /// RouterPipeline::new().fold(steps).build()? + /// ``` + pub fn fold(self, steps: I) -> Self + where + I: IntoIterator, + F: FnOnce(Router<()>) -> Result>, + { + steps.into_iter().fold(self, |p, step| p.and_then(step)) + } + + /// Apply a dynamic collection of infallible `Router -> Router` transforms, + /// left-to-right (fold over `map`). + /// + /// Each item is a [`RouterTransform`] (`Box) -> Router<()>>`) + /// so heterogeneous transforms (different layer types) can coexist in one + /// collection. For a small, static set of layers, chaining `.map()` is cleaner. + /// + /// ```ignore + /// let transforms: Vec = vec![ + /// Box::new(|r| r.layer(TraceLayer::new_for_http())), + /// Box::new(|r| r.layer(CorsLayer::permissive())), + /// ]; + /// + /// RouterPipeline::new() + /// .mount::(svc) + /// .layer_all(transforms) + /// .build()? + /// ``` + pub fn layer_all(self, transforms: impl IntoIterator) -> Self { + transforms.into_iter().fold(self, |p, f| p.map(f)) + } + + /// Run a sub-pipeline and nest all of its routes under `prefix`. + /// + /// All controllers and routes registered inside the closure `f` will have + /// `prefix` prepended to their paths before being merged into the outer + /// router. This is the scoped functor: mapping a prefix transformation + /// over an enclosed group of routes. + /// + /// ```ignore + /// RouterPipeline::new() + /// .group("/api/v1", |g| g + /// .mount::(health_svc) + /// .mount::(echo_svc) + /// ) + /// .group("/internal", |g| g + /// .mount_if::(config.enable_metrics, metrics_svc) + /// ) + /// .build()? + /// ``` + pub fn group(self, prefix: &str, f: F) -> Self + where + F: FnOnce(RouterPipeline) -> RouterPipeline, + { + let prefix = prefix.to_owned(); + self.and_then(move |outer| { + let inner = f(RouterPipeline::new()).build()?; + Ok(outer.merge(Router::new().nest(&prefix, inner))) + }) + } +} + +impl Default for RouterPipeline { + fn default() -> Self { + Self::new() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::{controller::Controller, error::Result, router::Router}; + use axum::{body::Body, http::Request, routing::get}; + use tower::ServiceExt; + + // ----------------------------------------------------------------------- + // Minimal test controller — state is `()`, handler returns a static string. + // Manually implements `Controller` so the test module has no external deps. + // ----------------------------------------------------------------------- + + struct PingController; + + impl Controller for PingController { + type State = (); + fn mount(state: Arc) -> impl FnOnce(Router<()>) -> Result> { + move |router| { + let scoped: Router> = + Router::new().route("/ping", get(|| async { "pong" })); + Ok(router.merge(scoped.with_state(state))) + } + } + } + + fn ping_state() -> Arc<()> { + Arc::new(()) + } + + async fn status(app: Router<()>, uri: &str) -> u16 { + app.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap() + .status() + .as_u16() + } + + // ----------------------------------------------------------------------- + // mount_guarded + // ----------------------------------------------------------------------- + + #[test] + fn mount_guarded_short_circuits_on_err_guard() { + let result = RouterPipeline::new() + .mount_guarded::(ping_state(), || { + Err(crate::error::Error::other("guard failed")) + }) + .build(); + + assert!( + result.is_err(), + "build() should return Err when guard fails" + ); + } + + #[tokio::test] + async fn mount_guarded_registers_route_on_ok_guard() { + let app = RouterPipeline::new() + .mount_guarded::(ping_state(), || Ok(())) + .build() + .expect("build should succeed when guard passes"); + + assert_eq!(status(app, "/ping").await, 200); + } + + // ----------------------------------------------------------------------- + // mount_if + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn mount_if_false_route_returns_404() { + let app = RouterPipeline::new() + .mount_if::(false, ping_state()) + .build() + .expect("build should succeed even when mount_if is false"); + + assert_eq!(status(app, "/ping").await, 404); + } + + #[tokio::test] + async fn mount_if_true_route_returns_200() { + let app = RouterPipeline::new() + .mount_if::(true, ping_state()) + .build() + .expect("build should succeed when mount_if is true"); + + assert_eq!(status(app, "/ping").await, 200); + } + + // ----------------------------------------------------------------------- + // group prefix + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn group_prefix_is_applied_to_routes() { + let app = RouterPipeline::new() + .group("/v1", |g| g.mount::(ping_state())) + .build() + .expect("build should succeed"); + + assert_eq!( + status(app.clone(), "/v1/ping").await, + 200, + "/v1/ping should be 200" + ); + assert_eq!( + status(app, "/ping").await, + 404, + "/ping without prefix should be 404" + ); + } + + // ----------------------------------------------------------------------- + // Error propagation + // ----------------------------------------------------------------------- + + #[test] + fn error_from_and_then_propagates_through_remaining_steps() { + let result = RouterPipeline::new() + .and_then(|_| Err(crate::error::Error::other("intentional failure"))) + .mount::(ping_state()) // should never run + .build(); + + assert!( + result.is_err(), + "error should propagate through the rest of the pipeline" + ); + } +} diff --git a/crates/rust-api/src/router.rs b/crates/rust-api/src/router.rs index 6d30611..6ed2351 100644 --- a/crates/rust-api/src/router.rs +++ b/crates/rust-api/src/router.rs @@ -4,6 +4,28 @@ //! types. Users interact through the router module rather than importing Router //! directly. +use axum::routing::{on, MethodFilter}; + +/// Maps an HTTP method string (from a route annotation constant) to an Axum +/// [`MethodFilter`]. Used internally by the `mount_handlers!` macro. +/// +/// Panics on an unrecognised method string — this indicates a bug in the +/// framework's macro layer, not a user error. +pub fn method_filter_from_str(method: &str) -> MethodFilter { + match method { + "GET" => MethodFilter::GET, + "POST" => MethodFilter::POST, + "PUT" => MethodFilter::PUT, + "DELETE" => MethodFilter::DELETE, + "PATCH" => MethodFilter::PATCH, + other => panic!( + "Unknown HTTP method '{}' in route annotation. \ + Use #[get], #[post], #[put], #[delete], or #[patch].", + other + ), + } +} + /// Re-export Axum's Router type /// /// Note: In Axum's type system, `Router` means a router that "needs" state @@ -27,7 +49,7 @@ pub type Router = axum::Router; /// use rust_api_core::{router, routing}; /// /// let app = router::build() -/// .route("/health", routing::get(health_check)) +/// .api_route(__health_check_route, health_check) /// .layer(TraceLayer::new_for_http()) /// .finish(); /// ``` @@ -35,6 +57,64 @@ pub fn build() -> Router<()> { axum::Router::new() } +/// Extension trait for registering routes using the macro-generated +/// `(&'static str, &'static str)` route info tuple. +/// +/// This is the **enforcement contract**: the HTTP verb in the route info tuple +/// (set by the `#[get]`, `#[post]`, etc. annotation) is the sole authority on +/// the HTTP method. It is impossible to accidentally register a `#[get]` +/// handler as a `POST` endpoint. +/// +/// # Example +/// +/// ```ignore +/// // health_check is annotated #[get("/health")], so __health_check_route is +/// // ("/health", "GET"). api_route enforces that it is registered as GET. +/// router.api_route(__health_check_route, health_check) +/// ``` +pub trait ApiRoute +where + S: Clone + Send + Sync + 'static, +{ + /// Register a handler using the `(path, method)` tuple produced by a route + /// macro annotation. The HTTP verb is taken from the tuple — it cannot be + /// overridden at the call site. + fn api_route(self, route_info: (&'static str, &'static str), handler: H) -> Self + where + H: axum::handler::Handler, + T: 'static; +} + +impl ApiRoute for Router +where + S: Clone + Send + Sync + 'static, +{ + fn api_route(self, route_info: (&'static str, &'static str), handler: H) -> Self + where + H: axum::handler::Handler, + T: 'static, + { + let (path, method) = route_info; + + // Map the method string (from the annotation) to a MethodFilter. + // MethodFilter is Copy, so handler is moved exactly once into on(). + let filter = match method { + "GET" => MethodFilter::GET, + "POST" => MethodFilter::POST, + "PUT" => MethodFilter::PUT, + "DELETE" => MethodFilter::DELETE, + "PATCH" => MethodFilter::PATCH, + other => panic!( + "Unknown HTTP method '{}' from route annotation. \ + Use #[get], #[post], #[put], #[delete], or #[patch].", + other + ), + }; + + self.route(path, on(filter, handler)) + } +} + /// Extension trait to add a `finish()` method to Router /// /// This provides a clear endpoint to router building, making the API more diff --git a/crates/rust-api/tests/pipeline_integration.rs b/crates/rust-api/tests/pipeline_integration.rs new file mode 100644 index 0000000..70d724a --- /dev/null +++ b/crates/rust-api/tests/pipeline_integration.rs @@ -0,0 +1,340 @@ +//! Integration tests for `RouterPipeline`. +//! +//! Each test builds a minimal router via the public API, fires an in-process +//! request using `tower::ServiceExt::oneshot`, and asserts on status + body. +//! No TCP server is started — requests are processed entirely in-process. + +use axum::{body::Body, http::Request}; +use http_body_util::BodyExt; +use rust_api::prelude::*; +use tower::ServiceExt; + +// --------------------------------------------------------------------------- +// Test services +// --------------------------------------------------------------------------- + +pub struct PingService; + +impl PingService { + pub fn new() -> Self { + Self + } + pub fn ping(&self) -> &'static str { + "pong" + } +} + +pub struct MessageService; + +impl MessageService { + pub fn new() -> Self { + Self + } + pub fn greet(&self, name: &str) -> String { + format!("hello, {name}") + } +} + +// --------------------------------------------------------------------------- +// Test controllers +// --------------------------------------------------------------------------- + +#[get("/ping")] +pub async fn ping_handler(State(svc): State>) -> &'static str { + svc.ping() +} + +pub struct PingController; +mount_handlers!( + PingController, + PingService, + [(__ping_handler_route, ping_handler)] +); + +#[derive(Serialize, Deserialize)] +pub struct GreetRequest { + pub name: String, +} + +#[post("/greet")] +pub async fn greet_handler( + State(svc): State>, + Json(body): Json, +) -> String { + svc.greet(&body.name) +} + +pub struct MessageController; +mount_handlers!( + MessageController, + MessageService, + [(__greet_handler_route, greet_handler)] +); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async fn body_string(body: axum::body::Body) -> String { + let bytes = body.collect().await.unwrap().to_bytes(); + String::from_utf8(bytes.to_vec()).unwrap() +} + +async fn get_request(app: Router<()>, uri: &str) -> (u16, String) { + let resp = app + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = resp.status().as_u16(); + let body = body_string(resp.into_body()).await; + (status, body) +} + +// --------------------------------------------------------------------------- +// Tests — basic routing +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn get_ping_returns_200_with_body() { + let app = RouterPipeline::new() + .mount::(Arc::new(PingService::new())) + .build() + .unwrap(); + + let (status, body) = get_request(app, "/ping").await; + assert_eq!(status, 200); + assert_eq!(body, "pong"); +} + +#[tokio::test] +async fn post_greet_returns_200_with_greeting() { + let app = RouterPipeline::new() + .mount::(Arc::new(MessageService::new())) + .build() + .unwrap(); + + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/greet") + .header("content-type", "application/json") + .body(Body::from(r#"{"name":"Alice"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status().as_u16(), 200); + let body = body_string(resp.into_body()).await; + assert_eq!(body, "hello, Alice"); +} + +// --------------------------------------------------------------------------- +// Tests — verb enforcement +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn get_on_post_only_route_returns_405() { + let app = RouterPipeline::new() + .mount::(Arc::new(MessageService::new())) + .build() + .unwrap(); + + let resp = app + .oneshot( + Request::builder() + .method("GET") + .uri("/greet") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status().as_u16(), + 405, + "GET on a POST-only route should return 405" + ); +} + +// --------------------------------------------------------------------------- +// Tests — require_bearer auth middleware +// --------------------------------------------------------------------------- + +fn authed_app(token: &str) -> Router<()> { + RouterPipeline::new() + .mount::(Arc::new(PingService::new())) + .map(require_bearer(token.to_owned())) + .build() + .unwrap() +} + +#[tokio::test] +async fn correct_bearer_token_returns_200() { + let app = authed_app("my-secret"); + let resp = app + .oneshot( + Request::builder() + .uri("/ping") + .header("Authorization", "Bearer my-secret") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 200); +} + +#[tokio::test] +async fn wrong_bearer_token_returns_401() { + let app = authed_app("my-secret"); + let resp = app + .oneshot( + Request::builder() + .uri("/ping") + .header("Authorization", "Bearer wrong") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status().as_u16(), 401); +} + +#[tokio::test] +async fn missing_auth_header_returns_401() { + let app = authed_app("my-secret"); + let (status, _) = get_request(app, "/ping").await; + assert_eq!(status, 401); +} + +// --------------------------------------------------------------------------- +// Tests — mount_if +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn mount_if_false_route_returns_404() { + let app = RouterPipeline::new() + .mount_if::(false, Arc::new(PingService::new())) + .build() + .unwrap(); + + let (status, _) = get_request(app, "/ping").await; + assert_eq!( + status, 404, + "route should not be registered when mount_if condition is false" + ); +} + +#[tokio::test] +async fn mount_if_true_route_returns_200() { + let app = RouterPipeline::new() + .mount_if::(true, Arc::new(PingService::new())) + .build() + .unwrap(); + + let (status, _) = get_request(app, "/ping").await; + assert_eq!(status, 200); +} + +// --------------------------------------------------------------------------- +// Tests — mount_guarded +// --------------------------------------------------------------------------- + +#[test] +fn mount_guarded_failing_guard_causes_build_to_err() { + let result = RouterPipeline::new() + .mount_guarded::(Arc::new(PingService::new()), || { + Err(Error::other("required config missing")) + }) + .build(); + + assert!(result.is_err(), "build() must return Err when guard fails"); +} + +#[tokio::test] +async fn mount_guarded_passing_guard_registers_route() { + let app = RouterPipeline::new() + .mount_guarded::(Arc::new(PingService::new()), || Ok(())) + .build() + .unwrap(); + + let (status, _) = get_request(app, "/ping").await; + assert_eq!(status, 200); +} + +// --------------------------------------------------------------------------- +// Tests — group prefix +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn group_prefix_applied_to_nested_routes() { + let app = RouterPipeline::new() + .group("/api/v1", |g| { + g.mount::(Arc::new(PingService::new())) + }) + .build() + .unwrap(); + + let (prefixed_status, _) = get_request(app.clone(), "/api/v1/ping").await; + let (bare_status, _) = get_request(app, "/ping").await; + + assert_eq!(prefixed_status, 200, "/api/v1/ping should be 200"); + assert_eq!(bare_status, 404, "/ping without prefix should be 404"); +} + +#[tokio::test] +async fn group_auth_scoped_to_group_only() { + let app = RouterPipeline::new() + .mount::(Arc::new(PingService::new())) + .group("/admin", |g| { + g.mount::(Arc::new(MessageService::new())) + .map(require_bearer("admin-token")) + }) + .build() + .unwrap(); + + // Public route accessible without auth + let (pub_status, _) = get_request(app.clone(), "/ping").await; + assert_eq!(pub_status, 200, "public route should not require auth"); + + // Admin route blocked without token + let admin_no_token = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/greet") + .header("content-type", "application/json") + .body(Body::from(r#"{"name":"Bob"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + admin_no_token.status().as_u16(), + 401, + "admin route should require auth" + ); + + // Admin route accessible with correct token + let admin_with_token = app + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/greet") + .header("content-type", "application/json") + .header("Authorization", "Bearer admin-token") + .body(Body::from(r#"{"name":"Bob"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + admin_with_token.status().as_u16(), + 200, + "admin route should succeed with correct token" + ); +} diff --git a/docs/CompositionalRefactor.md b/docs/CompositionalRefactor.md new file mode 100644 index 0000000..637b50e --- /dev/null +++ b/docs/CompositionalRefactor.md @@ -0,0 +1,534 @@ +# Compositional Refactor: From DI Container to Kleisli Pipeline + +> **TL;DR** — We replaced a runtime DI type-map registry with a compile-time, +> monadic router pipeline. Controllers are now pure route descriptors. Auth is a +> Tower layer applied to a route group. The type system enforces all of this at +> compile time. Zero runtime overhead. Zero axum imports in user code. +> see the following song for more information: https://www.youtube.com/watch?v=M_B9d-22fwo +> astute readers will notice that Oblio is point free + +--- + +## The Problem with the Original Design + +The original `rust-api` had a `Container` — a `HashMap>` +that stored services as `Arc` and resolved them by type +at startup. Services had to implement an `Injectable` marker trait. The +container itself was a pure service registry — it had no knowledge of routing. +The problems were in what the container forced on the rest of the design: + +```rust +// setup_container() — registration is manual and order-dependent +fn setup_container() -> Container { + let mut container = Container::new(); + container.register_factory(HealthService::new); + container.register_factory(EchoService::new); + container +} + +// build_router() — each service resolved individually, router wired by hand +fn build_router(container: &Container) -> Router { + let health_svc = container.resolve::().unwrap(); // panics if forgotten + let echo_svc = container.resolve::().unwrap(); // panics if forgotten + + let health_router = Router::new() + .route(__health_check_route, routing::get(health_check)) // verb not enforced + .with_state(health_svc); + + let echo_router = Router::new() + .route(__echo_route, routing::post(echo)) + .with_state(echo_svc); + + router::build() + .merge(health_router) + .merge(echo_router) + .layer(TraceLayer::new_for_http()) +} +``` + +- **`resolve().unwrap()` is a runtime panic** — a forgotten `register_factory` + call produces a panic at startup, not a compiler error. There is no + compile-time proof that every service you need has been registered. +- **`Injectable` is mandatory boilerplate** — every service must implement a + marker trait that carries no behaviour and provides no type-safety benefit + beyond what `Send + Sync + 'static` already gives you. +- **The dependency graph is invisible** — nothing in the type signatures tells + you which services `build_router` requires. The contract is implicit and + enforced only at runtime. +- **The HTTP verb annotation is ignored** — `#[post("/echo")]` on the handler, + but `routing::get(echo)` in the router? The macro produces a path constant; + the method is whatever you manually pass to `routing::get/post/put`. They + can silently disagree. + +--- + +## The New Design: Kleisli Composition + +The refactor replaced the container with a pipeline of Kleisli arrows, where +each `mount` step is a **`Router<()> -> Result>`** function threaded +via `Result::and_then` (`>>=`). The entire build phase is pure: no runtime +lookups, no dynamic dispatch on service types, and errors short-circuit the +chain rather than panicking later. + +``` +Arc ──▶ HealthController::mount ─┐ +Arc ──▶ EchoController::mount ─┤ >=> RouterPipeline ──▶ Result +Arc ──▶ AdminController::mount ─┘ +``` + +### What a Controller Is Now + +```rust +// Controller is a zero-knowledge marker type. +// It produces a Kleisli arrow — nothing more. +pub trait Controller: Sized + 'static { + type State: Send + Sync + 'static; + + fn mount(state: Arc) + -> impl FnOnce(Router<()>) -> Result>; +} +``` + +A controller file contains exactly: +1. Handler functions annotated with `#[get]`, `#[post]`, etc. +2. A marker struct (`pub struct FooController;`) +3. A `mount_handlers!(...)` macro call + +No imports of `Router`. No imports of `axum`. No `Container`. No auth. + +The handler function itself was always clean — `#[get("/health")]` over a plain +`async fn`. What changed is how the framework consumes it: + +```rust +// BEFORE — route constant was a bare path string; verb stated separately in main.rs +// health_controller.rs: +pub const __health_check_route: &str = "/health"; // generated by #[get("/health")] + +// main.rs (build_router): +let health_router = Router::new() + .route(__health_check_route, routing::get(health_check)) // verb chosen here + .with_state(health_svc); // could silently be .put() + +// AFTER — route constant is a (path, verb) tuple; verb is enforced by mount_handlers! +// health_controller.rs: +pub const __health_check_route: (&'static str, &'static str) = ("/health", "GET"); + +mount_handlers!(HealthController, HealthService, [ + (__health_check_route, health_check), // verb comes from the annotation, always +]); +``` + +`mount_handlers!` is a `macro_rules!` (not a proc macro) because it needs to +reference `crate::router::method_filter_from_str` — a framework-internal symbol +that doesn't exist in the macro crate's namespace. It generates the full +`Controller` impl, including verb enforcement, from the `(path, verb)` tuple. + +### What the Pipeline Is + +`RouterPipeline` wraps `Result>` and exposes an algebraic API: + +| Method | FP Concept | What it does | +|--------|-----------|--------------| +| `.mount::(svc)` | Kleisli composition (`>=>`) | Compose controller's arrow into the pipeline | +| `.map(f)` | Functor (`fmap`) | Infallible `Router -> Router` transform | +| `.and_then(f)` | Monad bind (`>>=`) | Fallible `Router -> Result` | +| `.mount_if::(bool, svc)` | Conditional `>=>` | Compose arrow only when condition is true | +| `.mount_guarded::(svc, g)` | Guarded `>=>` | Compose arrow or short-circuit at startup | +| `.group(prefix, \|g\| …)` | Scoped functor | Sub-pipeline with path prefix | +| `.fold(steps)` | Catamorphism | Apply a dynamic list of Kleisli arrows left-to-right | +| `.layer_all(transforms)` | `fold` over `fmap` | Apply a list of `Router -> Router` fns | +| `.build()` | Interpreter | Unwrap the pipeline into `Result` | + +> **`>=>` vs `>>=`**: these are related but distinct. `>>=` (bind) is the +> *mechanism* — `Result::and_then(arrow)` applies a single Kleisli arrow to the +> current monadic value. `>=>` (fish) is the *composition operator* — it +> produces a new arrow that is the sequential composition of two existing arrows. +> Each `.mount()` call uses `>>=` internally, but what the pipeline *expresses* +> as a whole is a chain of arrows composed via `>=>`: +> `HealthController::mount >=> EchoController::mount >=> AdminController::mount`. + +The full composition for the `basic-api` example: + +```rust +let app = RouterPipeline::new() + // group: scoped functor — prefix applied inside the closure + .group("/api/v1", |g| g + .mount::(health_svc) // Kleisli bind + .mount::(echo_svc) // Kleisli bind + ) + // mount_if: identity when false, bind when true + .mount_if::(env::var("ENABLE_METRICS").is_ok(), metrics_svc) + // mount_guarded: bind or short-circuit the entire pipeline + .group("/admin", |g| g + .mount_guarded::(admin_svc, || { + if admin_key.is_empty() { + Err(Error::other("ADMIN_API_KEY required")) + } else { Ok(()) } + }) + // require_bearer = Router -> Router, applied via map inside the group + .map(require_bearer(admin_key.clone())) + ) + .route(__root_route, root) + // layer_all: catamorphism over middleware transforms + .layer_all(vec![ + Box::new(|r| r.layer(TraceLayer::new_for_http())) as RouterTransform, + Box::new(|r| r.layer(CorsLayer::permissive())) as RouterTransform, + ]) + .build() + .expect("Failed to build router"); +``` + +--- + +## Why Not the DI Container? + +### The container solves a problem you don't have in Rust + +DI containers are a fixture of statically typed, object-oriented languages — +Java Spring, C# .NET — where deeply nested object graphs would otherwise require +hundreds of lines of manual constructor wiring, and where interface-based +programming (`IUserService` vs `UserService`) demands a runtime resolver to swap +implementations. The container is the solution to *that* problem. + +Rust already has the thing the container is trying to provide — it's called +**the type system**. + +```rust +// This does not compile if `UserService` is the wrong type +let app = RouterPipeline::new() + .mount::(Arc::new(UserService::new())) + // ^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^ + // Controller::State = UserService is checked at compile time +``` + +If you pass `Arc` to `UserController::mount`, the compiler +rejects it. No container needed. + +### The container loses information at runtime + +```rust +// Container — errors are runtime panics +container.register(Arc::new(UserService::new())); +let svc = container.resolve::().unwrap(); // panics if missing + +// Pipeline — errors are compile-time type errors or build() Results +RouterPipeline::new() + .mount::(user_svc) // type mismatch → compiler error + .build()? // guard failure → Result::Err, no panic +``` + +A `HashMap>` is a runtime `Option` that you pay for +dynamically. `Arc` passed directly to `.mount::()` is compile-time proof. + +### The container cannot express conditional or guarded mounting + +With a container, "mount this controller only if an env var is set" requires +ad-hoc if/else branching around manual router construction. The pipeline makes +this a first-class combinator: + +```rust +.mount_if::(config.enable_metrics, metrics_svc) +.mount_guarded::(admin_svc, || { + if admin_key.is_empty() { Err(Error::other("key required")) } + else { Ok(()) } +}) +``` + +`mount_guarded` in particular gives you server-startup refusal for +misconfigured required features — a hard guarantee a container can't express. + +### The container cannot express grouped middleware + +Tower layers applied to route sub-groups require composing routers in a +specific order. The `group` combinator makes this a first-class concept: + +```rust +// Auth applies ONLY to /admin/* routes, not the whole app +.group("/admin", |g| g + .mount::(admin_svc) + .map(require_bearer(admin_key)) // layer scoped to this group +) +``` + +With a container this requires manually managing nested `Router` values and +`.nest()` calls — imperative, error-prone, invisible at the type level. + +--- + +## Why `require_bearer` Lives in `middleware.rs`, Not in the Handler + +Axum provides `FromRequestParts` — a powerful per-request extractor that can +short-circuit the request pipeline before a handler runs. It is the right tool +for deriving typed values from a request (parsed headers, session data, a +decoded JWT claims struct). It is the *wrong* tool for enforcing a security +policy on a route group, for three reasons: + +1. **It puts policy in the handler signature** — if auth lives in an extractor + argument, every handler must opt in. A new handler added to the same + controller can silently omit it and become unprotected. + +2. **It pulls secrets into the service** — to validate a token against a + configured key, the service must hold that key. A business-logic service + that knows about API keys is doing two jobs. + +3. **Auth is not composable at the handler level** — changing the auth scheme + (bearer → JWT → mTLS) means touching every handler signature. Auth as a + layer means changing one call site. + +The correct model, mirroring NestJS Guards and Next.js Middleware: + +```rust +// auth applied at the routing layer, not in the handler +.group("/admin", |g| g + .mount::(admin_svc) // pure handler, no auth knowledge + .map(require_bearer(admin_key)) // Tower layer covers the group +) +``` + +`AdminController` is unconditionally pure. If you add a second admin endpoint, +it is automatically protected. Auth changes in one place. + +`require_bearer` returns `impl Fn(Router<()>) -> Router<()>` — exactly what +`.map()` accepts. No trait naming, no boilerplate, no `axum` imports. + +--- + +## Comparison to NestJS + +NestJS wraps Express the same way `rust-api` wraps Axum. The parallel is tight: + +| Concept | NestJS | rust-api | +|---------|--------|----------| +| Route group + prefix | `@Controller('/api/v1')` | `.group("/api/v1", \|g\| …)` | +| Conditional wiring | Module `imports` conditional in `AppModule` | `.mount_if::(condition, svc)` | +| Required config check | `ConfigService` + factory guard | `.mount_guarded::(svc, guard)` | +| Auth middleware | `@UseGuards(AuthGuard)` on controller class | `.map(require_bearer(key))` on group | +| DI | `@Injectable()` + `@Module({ providers })` | `Arc::new(MyService::new())` | +| Handler purity | Controllers know `@UseGuards` decorators | Controllers know nothing | + +The critical difference is **where errors live**. NestJS guards throw at +request time — if you misconfigure a guard, you find out during a test or in +production. `mount_guarded` fails at `build()` time — the server never starts. +Rust's type system eliminates an entire class of "it worked in dev" bugs. + +NestJS also uses Observables (RxJS) in its internals — functional reactive +streams for async request processing. The `RouterPipeline` is analogous: +a monadic chain over `Result`, where each step is a composable arrow. The +difference is Rust does this statically. There is no runtime Observable +machinery, no subscribe/unsubscribe, no teardown logic. The pipeline builds a +`Router<()>` at startup and disappears. The resulting router has zero overhead +compared to a hand-written axum app. + +--- + +## How This Enables OpenAPI Generation + +The old model — `router.route("/health", routing::get(health_check))` — throws +away the structure. By the time `build_router()` returns, you have a black-box +`Router`. No metadata about paths, methods, handler signatures, or response +types survives. + +The new model preserves structure at every level that matters: + +### 1. Route constants carry the verb + +```rust +// Generated by #[get("/health")]: +pub const __health_check_route: (&'static str, &'static str) = ("/health", "GET"); +``` + +The path and method are available as `&'static str` at compile time. An +OpenAPI generator can iterate over all route constants in a crate and build +the path/operation table without runtime reflection. + +### 2. Controllers declare their state type + +```rust +pub trait Controller { + type State: Send + Sync + 'static; + fn mount(state: Arc) -> impl FnOnce(Router<()>) -> Result>; +} +``` + +An OpenAPI generator can walk the controller's associated type to discover +request/response types. Combined with `serde` derive info (or `schemars`), +this produces the full schema. + +### 3. The pipeline expresses the route tree + +`group("/api/v1", ...)` → `"/api/v1"` prefix in OpenAPI paths. +`mount_if(condition, svc)` → conditionally included paths. +`mount_guarded(svc, g)` → required-feature flag in OpenAPI `x-` extensions. + +A future `RouterPipeline::openapi()` method could intercept the pipeline +before `build()`, walk the controller graph, and produce an OpenAPI 3.0 spec: + +```rust +let (app, spec) = RouterPipeline::new() + .group("/api/v1", |g| g + .mount::(health_svc) + .mount::(echo_svc) + ) + .mount_guarded::(admin_svc, || admin_check()) + .build_with_openapi()?; +// ^^^^^^^^^^^^^^^^^^^ returns (Router, OpenApiSpec) +``` + +This is only possible because the pipeline retains structural information. A +plain `router.route(...)` call discards it immediately. + +### 4. Validation hooks in naturally + +The plan already includes applicative validation via a `Validated` type +and a `Valid` Axum extractor. Because controllers are pure functions and +services are typed, the validation layer can: + +1. Derive JSON Schema from `#[derive(Validate)]` on request types. +2. Register those schemas against the route's path/method in the OpenAPI spec. +3. Return HTTP 422 with structured field-level errors — same shape as FastAPI. + +None of this requires changes to the pipeline or controllers. + +--- + +## Summary + +| Concern | Before | After | +|---------|--------|-------| +| Service wiring | `Container` (runtime HashMap) | `Arc` passed directly to `.mount::(svc)` | +| Error timing | Runtime panic in `container.resolve().unwrap()` | Compile error or `build()` Result | +| Verb enforcement | Path-only `&str` constant; verb chosen manually in `main.rs` | `(&str, &str)` tuple; verb from annotation, enforced by `mount_handlers!` | +| Router wiring | Manual `Router::new().route(...).with_state()` per service | `mount_handlers!` + `RouterPipeline::mount::()` | +| Conditional features | Ad-hoc `if` around manual router construction | `.mount_if()` / `.mount_guarded()` | +| Auth on a route group | Manual `Router::nest()` + layer juggling | `.group("/admin", \|g\| g.map(require_bearer(...)))` | +| axum in user code | Direct `axum` dependency required | Zero — all surface types via `rust_api::prelude::*` | +| OpenAPI potential | None — structure discarded at `route()` | High — path/method/type info retained | + +The pipeline is not clever for its own sake. Every algebraic abstraction +(`fmap`, `>>=`, catamorphism) maps directly to a concrete engineering +requirement: type-safe wiring, error propagation without panics, middleware +scoping, and the structural metadata that makes OpenAPI generation and +applicative validation possible. + +--- + +## Next Steps + +### Applicative Validation + +The most impactful near-term addition is a structured validation layer. The +goal: a request body that fails three field validations returns all three +errors at once — not just the first — in a consistent JSON shape. + +In functional programming, this is the job of an **applicative functor**. Where +a monad short-circuits on the first failure (`Result::and_then`), an applicative +*accumulates* failures independently and combines them at the end. F# expresses +this with `Validation` in libraries like FsToolkit; Haskell with `Validation` +from `validation` or `these`. + +The plan for `rust-api`: + +```rust +// A derive macro generates field-level validators +#[derive(Deserialize, Validate)] +pub struct CreateUser { + #[validate(email)] + pub email: String, + + #[validate(length(min = 8))] + pub password: String, + + #[validate(range(min = 18, max = 120))] + pub age: u8, +} + +// Valid is an Axum extractor that runs validation before the handler runs. +// All field errors are collected — no short-circuit. +#[post("/users")] +pub async fn create_user( + Valid(body): Valid, // 422 with all errors if invalid + State(svc): State>, +) -> Json { + Json(svc.create(body)) +} +``` + +A failed request returns HTTP 422 with a structured body: + +```json +{ + "errors": [ + { "field": "email", "message": "not a valid email address" }, + { "field": "password", "message": "must be at least 8 characters" } + ] +} +``` + +This mirrors FastAPI's validation response exactly — same field-level +granularity, same status code — but the validation logic is derived entirely +from the type at compile time, with no runtime schema registry. + +The underlying type is `Validated` — an applicative that accumulates +errors into a `Vec` rather than short-circuiting: + +```rust +// Monadic (short-circuits on first error): +validate_email(email)?; // stops here if invalid +validate_length(password)?; // never reached + +// Applicative (accumulates all errors): +let result = validate_email(email) + .zip(validate_length(password)) + .zip(validate_range(age)); // all three run regardless +``` + +### Domain Types and Models + +Validation is not only a boundary concern — it is the foundation for making +illegal states unrepresentable in the domain model itself. + +Once `Validated` exists, you can use it to construct **smart constructors** +for domain types that can only be created through validated paths: + +```rust +pub struct Email(String); // opaque — can't be constructed without validation + +impl Email { + pub fn parse(s: &str) -> Validated { + if is_valid_email(s) { + Validated::ok(Email(s.to_owned())) + } else { + Validated::err(FieldError::new("email", "not a valid email address")) + } + } +} + +pub struct CreateUserRequest { + pub email: Email, // already validated — no `String` leaking into domain + pub password: Password, + pub age: Age, +} +``` + +A service that accepts `CreateUserRequest` cannot receive an invalid email — +the type system prevents it. There is no runtime check inside the service +because the invalid state is not expressible. This is the Rust idiom "parse, +don't validate" applied systematically. + +The pipeline from request to domain looks like: + +``` +HTTP body (JSON) + → Deserialize (serde) + → Validate (Valid extractor, applicative accumulation) + → Construct domain (smart constructors, type-level guarantees) + → Service call (only ever sees valid, well-typed input) +``` + +Each stage is a pure transformation. The service never sees raw strings; the +handler never contains validation logic; the types make the constraints legible +to anyone reading the code without needing to trace runtime behaviour. + +This is the same philosophy as the pipeline itself: push decisions up to the +type level, make invalid configurations unrepresentable, and let the compiler +do the enforcement. diff --git a/examples/basic-api/Cargo.toml b/examples/basic-api/Cargo.toml index eaa52ae..482f805 100644 --- a/examples/basic-api/Cargo.toml +++ b/examples/basic-api/Cargo.toml @@ -10,3 +10,7 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1.0", features = ["derive"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +tower = { version = "0.4", features = ["util"] } +http-body-util = "0.1" diff --git a/examples/basic-api/src/controllers/admin_controller.rs b/examples/basic-api/src/controllers/admin_controller.rs new file mode 100644 index 0000000..6846ab6 --- /dev/null +++ b/examples/basic-api/src/controllers/admin_controller.rs @@ -0,0 +1,40 @@ +use rust_api::prelude::*; + +use crate::services::admin_service::{AdminService, AdminStatusResponse}; + +// --------------------------------------------------------------------------- +// Handler +// --------------------------------------------------------------------------- +// +// AdminController is a pure handler — it knows only about its service. +// Auth is a Tower layer applied to the route group in the pipeline: +// +// RouterPipeline::new() +// .group("/admin", |g| g +// .mount::(admin_svc) +// .map(|r| r.layer(require_bearer(key))) +// ) +// +// The handler never sees an unauthenticated request — the middleware rejects +// it before this code runs. No auth imports, no token comparisons here. + +/// GET /status — admin subsystem health. +/// Mounted under the /admin group in the pipeline, so the full path is /admin/status. +/// Auth is applied by require_bearer on the /admin group — no token logic here. +#[get("/status")] +pub async fn admin_status(State(svc): State>) -> Json { + Json(svc.status()) +} + +// --------------------------------------------------------------------------- +// Controller registration +// --------------------------------------------------------------------------- + +/// Controller marker for admin routes. +pub struct AdminController; + +mount_handlers!( + AdminController, + AdminService, + [(__admin_status_route, admin_status),] +); diff --git a/examples/basic-api/src/controllers/echo_controller.rs b/examples/basic-api/src/controllers/echo_controller.rs index 78aab12..758d95b 100644 --- a/examples/basic-api/src/controllers/echo_controller.rs +++ b/examples/basic-api/src/controllers/echo_controller.rs @@ -1,22 +1,30 @@ -use std::sync::Arc; - use rust_api::prelude::*; use crate::services::echo_service::{EchoResponse, EchoService}; -/// Request type for the echo endpoint. +/// Request body for the echo endpoint. #[derive(Debug, Serialize, Deserialize)] pub struct EchoRequest { pub message: String, } -/// Echo endpoint that echoes back the received message. -/// Uses dependency injection to access the EchoService. +/// POST /echo — echoes the message with an invocation counter. +/// +/// The `#[post]` annotation is a binding contract: this handler is always +/// registered as a POST endpoint. The verb cannot be overridden at mount time. #[post("/echo")] pub async fn echo( - State(service): State>, + State(svc): State>, Json(payload): Json, ) -> Json { - let response = service.echo(&payload.message); - Json(response) + Json(svc.echo(&payload.message)) } + +/// Controller marker for echo-related routes. +/// +/// Has no routing knowledge — `mount_handlers!` generates the Kleisli arrow +/// that the `RouterPipeline` threads via `and_then`. +pub struct EchoController; + +// Generates `impl Controller for EchoController` with the Kleisli mount fn. +mount_handlers!(EchoController, EchoService, [(__echo_route, echo),]); diff --git a/examples/basic-api/src/controllers/health_controller.rs b/examples/basic-api/src/controllers/health_controller.rs index faf1a08..894b207 100644 --- a/examples/basic-api/src/controllers/health_controller.rs +++ b/examples/basic-api/src/controllers/health_controller.rs @@ -1,13 +1,26 @@ -use std::sync::Arc; - use rust_api::prelude::*; use crate::services::health_service::{HealthResponse, HealthService}; -/// Health check endpoint that returns the service status. -/// Uses dependency injection to access the HealthService. +/// GET /health — returns service status. +/// +/// The `#[get]` annotation is a binding contract: this handler is always +/// registered as a GET endpoint. The verb cannot be overridden at mount time. #[get("/health")] -pub async fn health_check(State(service): State>) -> Json { - let response = service.health_check(); - Json(response) +pub async fn health_check(State(svc): State>) -> Json { + Json(svc.health_check()) } + +/// Controller marker for health-related routes. +/// +/// Has no routing knowledge — `mount_handlers!` generates the Kleisli arrow +/// that the `RouterPipeline` threads via `and_then`. +pub struct HealthController; + +// Generates `impl Controller for HealthController` with the Kleisli mount fn. +// Controllers know about: their service type, their handlers, and nothing else. +mount_handlers!( + HealthController, + HealthService, + [(__health_check_route, health_check),] +); diff --git a/examples/basic-api/src/controllers/metrics_controller.rs b/examples/basic-api/src/controllers/metrics_controller.rs new file mode 100644 index 0000000..9104c34 --- /dev/null +++ b/examples/basic-api/src/controllers/metrics_controller.rs @@ -0,0 +1,22 @@ +use rust_api::prelude::*; + +use crate::services::metrics_service::{MetricsResponse, MetricsService}; + +/// GET /metrics — returns runtime counters. +/// +/// This controller is only wired when `ENABLE_METRICS` is set in the +/// environment. The `mount_if` call in `main.rs` handles the condition — +/// this file has no knowledge of it. +#[get("/metrics")] +pub async fn metrics(State(svc): State>) -> Json { + Json(svc.record_and_snapshot()) +} + +/// Controller marker for metrics routes. +pub struct MetricsController; + +mount_handlers!( + MetricsController, + MetricsService, + [(__metrics_route, metrics),] +); diff --git a/examples/basic-api/src/controllers/mod.rs b/examples/basic-api/src/controllers/mod.rs index 6643207..2b0910a 100644 --- a/examples/basic-api/src/controllers/mod.rs +++ b/examples/basic-api/src/controllers/mod.rs @@ -1,2 +1,4 @@ +pub mod admin_controller; pub mod echo_controller; pub mod health_controller; +pub mod metrics_controller; diff --git a/examples/basic-api/src/main.rs b/examples/basic-api/src/main.rs index d8a6b4a..b0a5a35 100644 --- a/examples/basic-api/src/main.rs +++ b/examples/basic-api/src/main.rs @@ -4,37 +4,126 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod controllers; mod services; -// Import controller handlers and their macro-generated path constants use controllers::{ - echo_controller::{__echo_route, echo}, - health_controller::{__health_check_route, health_check}, + admin_controller::AdminController, echo_controller::EchoController, + health_controller::HealthController, metrics_controller::MetricsController, +}; +use services::{ + admin_service::AdminService, echo_service::EchoService, health_service::HealthService, + metrics_service::MetricsService, }; -use services::{echo_service::EchoService, health_service::HealthService}; -/// Root endpoint handler that returns a welcome message. +/// GET / — stateless root endpoint, mounted directly on the pipeline. #[get("/")] async fn root() -> &'static str { "Welcome to RustAPI!" } -/// Main entry point for the rust_api REST API server. -/// Demonstrates FastAPI-style routing with decorator macros and dependency -/// injection. +/// Main entry point. +/// +/// Demonstrates the full `RouterPipeline` feature set: +/// +/// - `group` — nest controllers under a versioned path prefix +/// - `mount` — unconditional Kleisli controller bind +/// - `mount_if` — optional bind; silently skipped when condition is false +/// - `mount_guarded` — startup refusal when required config is absent +/// - `require_bearer` — Tower auth transform scoped to a route group +/// - `layer_all` — fold a `Vec` over `map` +/// - `route` — stateless route (no controller, no service state) +/// +/// # Protected Route Pattern +/// +/// Admin routes use the correct protected-route model: +/// +/// ``` +/// .group("/admin", |g| g +/// .mount_guarded(svc, || key_present_check) // startup refusal +/// .map(require_bearer(key)) // auth applied to the group +/// ) +/// ``` +/// +/// - `mount_guarded` is a **build-time** check: if `ADMIN_API_KEY` is unset +/// the pipeline short-circuits and the server refuses to start. +/// +/// - `require_bearer` is a **request-time** Tower layer. It is applied via +/// `.map()` inside the `/admin` group, so only routes in that group require +/// authentication. `AdminController` has zero auth knowledge. +/// +/// This mirrors NestJS Guards / Next.js middleware: auth is a cross-cutting +/// concern applied at the routing layer, not inside handler logic. #[tokio::main] async fn main() { initialize_tracing(); - let container = setup_container(); - let app = build_router(&container); - // Start the server using RustAPI framework + // Services are plain Arc — no registry, no type-map. + let health_svc = Arc::new(HealthService::new()); + let echo_svc = Arc::new(EchoService::new()); + let metrics_svc = Arc::new(MetricsService::new()); + let admin_svc = Arc::new(AdminService::new()); + + // Read the bearer token once at startup. + // unwrap_or_default so construction never panics — mount_guarded is the gate. + let admin_key = std::env::var("ADMIN_API_KEY").unwrap_or_default(); + + let app = RouterPipeline::new() + // ── Public API ────────────────────────────────────────────────────── + // group: all public routes live under /api/v1 (scoped functor). + .group("/api/v1", |g| g + .mount::(health_svc) + .mount::(echo_svc) + ) + // mount_if: metrics wired only when ENABLE_METRICS is set. + // When false the pipeline passes through unchanged — no error, no routes. + .mount_if::( + std::env::var("ENABLE_METRICS").is_ok(), + metrics_svc, + ) + // ── Protected Admin Group ──────────────────────────────────────────── + // group("/admin", ...) scopes all admin routes under /admin/*. + // + // Step 1 — mount_guarded: startup refusal. + // If ADMIN_API_KEY is absent the pipeline returns Err here and + // .build() propagates it — the server refuses to start. + // This is intentional: enabling admin routes without a key is + // a misconfiguration, not a feature toggle. + // + // Step 2 — .map(require_bearer(key)): request-time auth. + // Applied inside the group so it covers exactly the /admin/* + // routes. Every request is rejected with 401 before any handler + // body executes. AdminController has no knowledge of this layer. + .group("/admin", |g| { + let key = admin_key.clone(); + g.mount_guarded::(admin_svc, move || { + if key.is_empty() { + Err(Error::other( + "ADMIN_API_KEY must be set — server will not start without it", + )) + } else { + Ok(()) + } + }) + .map(require_bearer(admin_key.clone())) + }) + // ── Stateless root ────────────────────────────────────────────────── + .route(__root_route, root) + // ── Global middleware ─────────────────────────────────────────────── + // layer_all: fold a runtime Vec over map. + // Equivalent to chaining .map() but accepts a dynamically-built list. + .layer_all(vec![ + Box::new(|r: Router<()>| r.layer(TraceLayer::new_for_http())) as RouterTransform, + Box::new(|r: Router<()>| r.layer(CorsLayer::permissive())) as RouterTransform, + ]) + .build() + .expect("Failed to build router"); + RustAPI::new(app) - .port(3000) // Configurable port (default is 3000) + .port(3000) .serve() .await .expect("Failed to start server"); } -/// Initializes the tracing subscriber for logging +/// Initializes structured tracing/logging. fn initialize_tracing() { tracing_subscriber::registry() .with( @@ -44,44 +133,3 @@ fn initialize_tracing() { .with(tracing_subscriber::fmt::layer()) .init(); } - -/// Sets up the DI container with all services -fn setup_container() -> Container { - let mut container = Container::new(); - - // Register services - container.register_factory(HealthService::new); - container.register_factory(EchoService::new); - - container -} - -/// Builds the application router using FastAPI-style route decorators -/// Routes use macro-generated path constants for true decorator-based routing -fn build_router(container: &Container) -> Router { - // Resolve services from container - let health_service = container.resolve::().unwrap(); - let echo_service = container.resolve::().unwrap(); - - // Build separate routers for each service with their own state - // Note: Routes are added before calling with_state() - this is Axum's pattern - // Path comes from the #[get("/health")] macro! - let health_router = Router::new() - .route(__health_check_route, routing::get(health_check)) - .with_state(health_service); - - // Path comes from the #[post("/echo")] macro! - let echo_router = Router::new() - .route(__echo_route, routing::post(echo)) - .with_state(echo_service); - - // Merge all routers together - // Using router::build() as recommended entry point, but Router::new() also - // works - router::build() - .route(__root_route, routing::get(root)) - .merge(health_router) - .merge(echo_router) - .layer(TraceLayer::new_for_http()) - .layer(CorsLayer::permissive()) -} diff --git a/examples/basic-api/src/services/admin_service.rs b/examples/basic-api/src/services/admin_service.rs new file mode 100644 index 0000000..7974dd3 --- /dev/null +++ b/examples/basic-api/src/services/admin_service.rs @@ -0,0 +1,28 @@ +use rust_api::prelude::*; + +/// Response type for the admin status endpoint. +#[derive(Debug, Serialize, Deserialize)] +pub struct AdminStatusResponse { + pub status: String, + pub message: String, +} + +/// Admin service — pure business logic, no auth knowledge. +/// +/// Authentication is handled by the `require_bearer` Tower layer applied to the +/// admin route group in the pipeline. This service receives only authenticated +/// requests and has no concept of tokens or keys. +pub struct AdminService; + +impl AdminService { + pub fn new() -> Self { + Self + } + + pub fn status(&self) -> AdminStatusResponse { + AdminStatusResponse { + status: "ok".to_string(), + message: "Admin subsystem is operational.".to_string(), + } + } +} diff --git a/examples/basic-api/src/services/echo_service.rs b/examples/basic-api/src/services/echo_service.rs index 7cf644c..b636c2f 100644 --- a/examples/basic-api/src/services/echo_service.rs +++ b/examples/basic-api/src/services/echo_service.rs @@ -9,13 +9,12 @@ pub struct EchoResponse { pub count: u64, } -/// Echo Service implementation +/// Echo service — uses an atomic counter for lock-free call tracking. +/// Immutable interface: `&self` only. State changes via AtomicU64, not Mutex. pub struct EchoService { call_count: AtomicU64, } -impl Injectable for EchoService {} - impl EchoService { pub fn new() -> Self { Self { @@ -43,3 +42,48 @@ impl EchoService { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn echo_returns_message_with_count() { + let svc = EchoService::new(); + let resp = svc.echo("hello"); + assert_eq!(resp.count, 1); + assert_eq!(resp.data, "1: hello"); + } + + #[test] + fn echo_increments_counter_on_each_call() { + let svc = EchoService::new(); + let first = svc.echo("a"); + let second = svc.echo("b"); + let third = svc.echo("c"); + assert_eq!(first.count, 1); + assert_eq!(second.count, 2); + assert_eq!(third.count, 3); + } + + #[test] + fn echo_data_format_includes_count_prefix() { + let svc = EchoService::new(); + let resp = svc.echo("world"); + assert!( + resp.data.starts_with("1: "), + "data should start with ': '" + ); + assert!(resp.data.ends_with("world")); + } + + #[test] + fn echo_counter_is_independent_per_instance() { + let svc_a = EchoService::new(); + let svc_b = EchoService::new(); + svc_a.echo("x"); + svc_a.echo("x"); + let b_resp = svc_b.echo("x"); + assert_eq!(b_resp.count, 1, "each service instance has its own counter"); + } +} diff --git a/examples/basic-api/src/services/health_service.rs b/examples/basic-api/src/services/health_service.rs index 137a3c1..9d793f8 100644 --- a/examples/basic-api/src/services/health_service.rs +++ b/examples/basic-api/src/services/health_service.rs @@ -6,12 +6,12 @@ pub struct HealthResponse { pub status: String, } +/// Health service — immutable after construction. +/// All methods take `&self`; no `Mutex` needed. pub struct HealthService { - // state here + // state fields here } -impl Injectable for HealthService {} - impl HealthService { pub fn new() -> Self { Self { @@ -27,3 +27,23 @@ impl HealthService { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn health_check_returns_healthy_status() { + let svc = HealthService::new(); + let resp = svc.health_check(); + assert_eq!(resp.status, "healthy"); + } + + #[test] + fn health_check_is_idempotent() { + let svc = HealthService::new(); + let first = svc.health_check(); + let second = svc.health_check(); + assert_eq!(first.status, second.status); + } +} diff --git a/examples/basic-api/src/services/metrics_service.rs b/examples/basic-api/src/services/metrics_service.rs new file mode 100644 index 0000000..cde725b --- /dev/null +++ b/examples/basic-api/src/services/metrics_service.rs @@ -0,0 +1,39 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use rust_api::prelude::*; + +/// Response type for the metrics endpoint. +#[derive(Debug, Serialize, Deserialize)] +pub struct MetricsResponse { + pub requests_total: u64, + pub uptime_seconds: u64, +} + +/// Metrics service — tracks basic runtime counters. +/// +/// Immutable interface: `&self` only. Counters mutate via atomics, not `Mutex`. +/// This service is conditionally wired by `mount_if` in the pipeline — if +/// `ENABLE_METRICS` is not set, this struct is never placed in a `Router`. +pub struct MetricsService { + requests_total: AtomicU64, + started_at: std::time::Instant, +} + +impl MetricsService { + pub fn new() -> Self { + Self { + requests_total: AtomicU64::new(0), + started_at: std::time::Instant::now(), + } + } + + /// Increment the request counter and return a snapshot of current metrics. + pub fn record_and_snapshot(&self) -> MetricsResponse { + let requests_total = self.requests_total.fetch_add(1, Ordering::Relaxed) + 1; + let uptime_seconds = self.started_at.elapsed().as_secs(); + MetricsResponse { + requests_total, + uptime_seconds, + } + } +} diff --git a/examples/basic-api/src/services/mod.rs b/examples/basic-api/src/services/mod.rs index 09ba3af..a35e8b4 100644 --- a/examples/basic-api/src/services/mod.rs +++ b/examples/basic-api/src/services/mod.rs @@ -1,2 +1,4 @@ +pub mod admin_service; pub mod echo_service; pub mod health_service; +pub mod metrics_service; diff --git a/justfile b/justfile new file mode 100644 index 0000000..4f2f68d --- /dev/null +++ b/justfile @@ -0,0 +1,48 @@ +# Run all tests across the workspace +test: + cargo test --workspace + +# Run tests with HTML coverage report (opens in browser) +# Requires: cargo install cargo-llvm-cov +cov: + cargo llvm-cov --open + +# Generate LCOV coverage report (for CI) +cov-lcov: + cargo llvm-cov --lcov --output-path target/lcov.info + +# Run the example app (admin routes disabled — no ADMIN_API_KEY set) +run: + cargo run -p basic-api + +# Run with admin routes enabled +run-admin: + ADMIN_API_KEY=secret cargo run -p basic-api + +# Run with metrics endpoint enabled +run-metrics: + ENABLE_METRICS=1 cargo run -p basic-api + +# Run with all features enabled +run-full: + ADMIN_API_KEY=secret ENABLE_METRICS=1 cargo run -p basic-api + +# Check for compile errors without producing a binary +check: + cargo check --workspace + +# Format all source files +fmt: + cargo fmt --all + +# Run Clippy lints (warnings treated as errors) +lint: + cargo clippy --workspace -- -D warnings + +# Build release binary +build: + cargo build --release -p basic-api + +# Remove build artifacts +clean: + cargo clean