Chapter 21
Middleware & Composability
Cross-cutting concerns - logging, auth, CORS, timing - shouldn't be duplicated in every handler. Middleware wraps handlers with reusable layers that run before and after the core logic.
The Middleware Pattern: Wrapping Handlers
We introduced the middleware idea with closures in Chapter 11. Now let's build a proper, typed middleware system using traits.
The core idea: a middleware is something that takes a request and a "next" handler, and returns a response. It can inspect or modify the request before passing it along, and inspect or modify the response on the way back:
Request Response
│ ▲
▼ │
┌──────────────────────────────────────────┐
│ Logging middleware │
│ log request ──▶ ... ──▶ log response │
└──────────────────────────────────────────┘
│ ▲
▼ │
┌──────────────────────────────────────────┐
│ Auth middleware │
│ check token ──▶ ... ──▶ pass through │
│ OR return 401 early (skip inner) │
└──────────────────────────────────────────┘
│ ▲
▼ │
┌──────────────────────────────────────────┐
│ CORS middleware │
│ pass through ──▶ ... ──▶ add headers │
└──────────────────────────────────────────┘
│ ▲
▼ │
┌──────────────────────────────────────────┐
│ Handler │
│ process request ──▶ return response │
└──────────────────────────────────────────┘Let's define the types:
use std::sync::Arc;
use crate::error::ServerError;
use crate::request::Request;
use crate::response::Response;
/// The core service trait - anything that handles a request.
pub trait Service: Send + Sync {
fn call(&self, req: &Request) -> Result<Response, ServerError>;
}
/// Blanket impl: any matching function/closure is a Service.
impl<F> Service for F
where
F: Fn(&Request) -> Result<Response, ServerError> + Send + Sync,
{
fn call(&self, req: &Request) -> Result<Response, ServerError> {
(self)(req)
}
}
/// A middleware wraps an inner Service, producing a new Service.
pub trait Middleware: Send + Sync {
fn wrap(&self, inner: Arc<dyn Service>) -> Arc<dyn Service>;
}The design: Service is the handler trait (renamed fromHandler to match industry convention). Middleware takes an inner Service and returns a new Service that wraps it. This makes middleware composable - each layer wraps the layer below.
Service and Layer. We're building a simplified version of the same architecture.Request Logging Middleware
Our first middleware logs every request and response with timing. It runs the inner handler, measures the elapsed time, and logs the result:
use std::sync::Arc;
use std::time::Instant;
use tracing::{info, warn};
use crate::error::ServerError;
use crate::middleware::{Middleware, Service};
use crate::request::Request;
use crate::response::Response;
pub struct LoggingMiddleware;
impl Middleware for LoggingMiddleware {
fn wrap(&self, inner: Arc<dyn Service>) -> Arc<dyn Service> {
Arc::new(LoggingService { inner })
}
}
struct LoggingService {
inner: Arc<dyn Service>,
}
impl Service for LoggingService {
fn call(&self, req: &Request) -> Result<Response, ServerError> {
let start = Instant::now();
// Call the inner handler
let result = self.inner.call(req);
let elapsed = start.elapsed();
match &result {
Ok(resp) => {
info!(
method = %req.method,
path = %req.path,
status = resp.status,
elapsed_ms = elapsed.as_millis() as u64,
"Request handled"
);
}
Err(e) => {
warn!(
method = %req.method,
path = %req.path,
error = %e,
elapsed_ms = elapsed.as_millis() as u64,
"Request failed"
);
}
}
result
}
}The pattern is always the same: create a middleware struct (config), implement Middleware to produce a service struct (runtime), and implement Service on the service struct to define the behavior.
Authentication and Authorization Middleware
Auth middleware checks credentials before the handler runs. If authentication fails, it short-circuits with a 401 response - the inner handler is never called:
use std::sync::Arc;
use crate::error::ServerError;
use crate::middleware::{Middleware, Service};
use crate::request::Request;
use crate::response::Response;
pub struct AuthMiddleware {
/// Paths that don't require authentication.
pub public_paths: Vec<String>,
/// The expected token (in production, verify JWTs or session cookies).
pub token: String,
}
impl Middleware for AuthMiddleware {
fn wrap(&self, inner: Arc<dyn Service>) -> Arc<dyn Service> {
Arc::new(AuthService {
inner,
public_paths: self.public_paths.clone(),
token: self.token.clone(),
})
}
}
struct AuthService {
inner: Arc<dyn Service>,
public_paths: Vec<String>,
token: String,
}
impl Service for AuthService {
fn call(&self, req: &Request) -> Result<Response, ServerError> {
// Skip auth for public paths
if self.public_paths.iter().any(|p| req.path.starts_with(p)) {
return self.inner.call(req);
}
// Check the Authorization header
match req.headers.get("authorization") {
Some(header) if header == &format!("Bearer {}", self.token) => {
// Valid token - proceed to the handler
self.inner.call(req)
}
Some(_) => {
// Invalid token
let mut resp = Response::new(403, "Forbidden", "Invalid token");
resp.add_header("Content-Type", "text/plain");
Ok(resp)
}
None => {
// No token at all
let mut resp = Response::new(
401,
"Unauthorized",
"Authentication required",
);
resp.add_header("Content-Type", "text/plain");
resp.add_header("WWW-Authenticate", "Bearer");
Ok(resp)
}
}
}
}Key features of this middleware:
- Public paths bypass auth -
/,/health, or any path you configure. - Short-circuit on failure - the inner handler never sees unauthenticated requests. No risk of accidentally serving protected data.
- Proper HTTP semantics - 401 with
WWW-Authenticateheader tells clients how to authenticate. 403 means "I know who you are but you're not allowed."
== - use a constant-time comparison to prevent timing attacks. And don't store tokens in plaintext config - use environment variables or a secrets manager.CORS Headers Middleware
CORS (Cross-Origin Resource Sharing) headers are needed when a browser-based frontend on one domain calls your API on another. This middleware adds the required headers to every response and handles preflight OPTIONS requests:
use std::sync::Arc;
use crate::error::ServerError;
use crate::middleware::{Middleware, Service};
use crate::request::{Method, Request};
use crate::response::Response;
pub struct CorsMiddleware {
pub allowed_origins: Vec<String>,
pub allowed_methods: Vec<String>,
pub allowed_headers: Vec<String>,
pub max_age: u32,
}
impl CorsMiddleware {
/// Permissive defaults for development.
pub fn permissive() -> Self {
CorsMiddleware {
allowed_origins: vec!["*".to_string()],
allowed_methods: vec![
"GET", "POST", "PUT", "DELETE", "OPTIONS",
].into_iter().map(String::from).collect(),
allowed_headers: vec![
"Content-Type", "Authorization",
].into_iter().map(String::from).collect(),
max_age: 86400,
}
}
}
impl Middleware for CorsMiddleware {
fn wrap(&self, inner: Arc<dyn Service>) -> Arc<dyn Service> {
Arc::new(CorsService {
inner,
allowed_origins: self.allowed_origins.clone(),
allowed_methods: self.allowed_methods.join(", "),
allowed_headers: self.allowed_headers.join(", "),
max_age: self.max_age.to_string(),
})
}
}
struct CorsService {
inner: Arc<dyn Service>,
allowed_origins: Vec<String>,
allowed_methods: String,
allowed_headers: String,
max_age: String,
}
impl CorsService {
fn add_cors_headers(&self, resp: &mut Response, origin: Option<&str>) {
let allow_origin = if self.allowed_origins.contains(&"*".to_string()) {
"*"
} else {
origin.unwrap_or("*")
};
resp.add_header("Access-Control-Allow-Origin", allow_origin);
resp.add_header("Access-Control-Allow-Methods", &self.allowed_methods);
resp.add_header("Access-Control-Allow-Headers", &self.allowed_headers);
}
}
impl Service for CorsService {
fn call(&self, req: &Request) -> Result<Response, ServerError> {
let origin = req.headers.get("origin").map(|s| s.as_str());
// Handle preflight OPTIONS requests
if req.method == Method::Options {
let mut resp = Response::new(204, "No Content", "");
self.add_cors_headers(&mut resp, origin);
resp.add_header("Access-Control-Max-Age", &self.max_age);
return Ok(resp);
}
// Normal request - call inner, then add CORS headers
let mut resp = self.inner.call(req)?;
self.add_cors_headers(&mut resp, origin);
Ok(resp)
}
}CORS middleware demonstrates both patterns: modifying the response on the way out (adding headers), and short-circuiting for preflight requests (returning 204 without calling the inner handler).
Composing Middleware into a Stack
Now the key piece: stacking middleware together. Each middleware wraps the one below it. We build from the inside out - the handler first, then wrap it with each middleware layer:
use std::sync::Arc;
use crate::middleware::{Middleware, Service};
pub struct Stack {
layers: Vec<Box<dyn Middleware>>,
}
impl Stack {
pub fn new() -> Self {
Stack { layers: vec![] }
}
/// Add a middleware layer. Layers are applied bottom-to-top:
/// the first added is the innermost, the last added is the outermost.
pub fn push<M: Middleware + 'static>(&mut self, middleware: M) {
self.layers.push(Box::new(middleware));
}
/// Wrap a handler with all middleware layers.
pub fn wrap(&self, handler: Arc<dyn Service>) -> Arc<dyn Service> {
self.layers.iter().fold(handler, |svc, mw| mw.wrap(svc))
}
}The fold is the same technique from Chapter 11. Starting with the handler, it wraps each middleware around it in order. The last middleware pushed is the outermost - it runs first on the request and last on the response.
Now wire it all together:
use std::sync::Arc;
use middleware::auth::AuthMiddleware;
use middleware::cors::CorsMiddleware;
use middleware::logging::LoggingMiddleware;
use middleware::stack::Stack;
fn build_app() -> Arc<dyn middleware::Service> {
// 1. Build the core handler (router)
let router = Arc::new(handler::build_router());
// 2. Build the middleware stack
let mut stack = Stack::new();
// Inner → outer order:
// CORS runs closest to the handler
stack.push(CorsMiddleware::permissive());
// Auth runs before the handler but after logging
stack.push(AuthMiddleware {
public_paths: vec![
"/".to_string(),
"/health".to_string(),
"/api/public".to_string(),
],
token: std::env::var("API_TOKEN").unwrap_or_else(|_| "dev-token".into()),
});
// Logging is outermost - sees every request/response
stack.push(LoggingMiddleware);
// 3. Wrap the handler with the stack
stack.wrap(router)
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().init();
let app = build_app();
let addr = "127.0.0.1:8080";
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
tracing::info!(addr, "Listening");
loop {
if let Ok((stream, _)) = listener.accept().await {
let app = Arc::clone(&app);
tokio::spawn(async move {
// parse request from stream...
// let response = app.call(&request);
// send response to stream...
});
}
}
}The execution order for a request to GET /api/todos:
→ LoggingService.call() start timer
→ AuthService.call() check Authorization header
→ CorsService.call() pass through (not OPTIONS)
→ Router.call() match route, run handler
← CorsService add CORS headers
← AuthService pass through
← LoggingService log method, path, status, elapsedAnd for a rejected request:
→ LoggingService.call() start timer
→ AuthService.call() no Authorization header
← AuthService return 401 (handler never called)
← LoggingService log method, path, status=401, elapsedTesting Middleware
Because middleware and handlers share the same Service trait, testing is straightforward - no HTTP needed:
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn ok_handler() -> Arc<dyn Service> {
Arc::new(|_req: &Request| -> Result<Response, ServerError> {
Ok(Response::new(200, "OK", "success"))
})
}
fn test_request(path: &str, auth: Option<&str>) -> Request {
let mut headers = HashMap::new();
if let Some(token) = auth {
headers.insert("authorization".to_string(), token.to_string());
}
Request {
method: Method::Get,
path: path.to_string(),
headers,
body: None,
}
}
#[test]
fn auth_allows_public_paths() {
let mw = AuthMiddleware {
public_paths: vec!["/".to_string(), "/health".to_string()],
token: "secret".to_string(),
};
let svc = mw.wrap(ok_handler());
let resp = svc.call(&test_request("/health", None)).unwrap();
assert_eq!(resp.status, 200);
}
#[test]
fn auth_rejects_missing_token() {
let mw = AuthMiddleware {
public_paths: vec![],
token: "secret".to_string(),
};
let svc = mw.wrap(ok_handler());
let resp = svc.call(&test_request("/api/data", None)).unwrap();
assert_eq!(resp.status, 401);
}
#[test]
fn auth_accepts_valid_token() {
let mw = AuthMiddleware {
public_paths: vec![],
token: "secret".to_string(),
};
let svc = mw.wrap(ok_handler());
let resp = svc.call(&test_request(
"/api/data",
Some("Bearer secret"),
)).unwrap();
assert_eq!(resp.status, 200);
}
#[test]
fn cors_adds_headers() {
let mw = CorsMiddleware::permissive();
let svc = mw.wrap(ok_handler());
let resp = svc.call(&test_request("/", None)).unwrap();
// Check CORS headers were added to the response
let bytes = String::from_utf8_lossy(&resp.to_bytes());
assert!(bytes.contains("Access-Control-Allow-Origin"));
}
#[test]
fn cors_handles_preflight() {
let mw = CorsMiddleware::permissive();
let svc = mw.wrap(ok_handler());
let req = Request {
method: Method::Options,
path: "/api/todos".to_string(),
headers: HashMap::new(),
body: None,
};
let resp = svc.call(&req).unwrap();
assert_eq!(resp.status, 204);
}
#[test]
fn stack_composes_correctly() {
let mut stack = Stack::new();
stack.push(CorsMiddleware::permissive());
stack.push(LoggingMiddleware);
let svc = stack.wrap(ok_handler());
let resp = svc.call(&test_request("/", None)).unwrap();
assert_eq!(resp.status, 200);
}
}ok_handler. This verifies the middleware's behavior without involving the router, network, or other middleware. The stack test then verifies they compose without errors.Exercise
- Write a
RateLimitMiddlewarethat tracks request counts per IP using anArc<Mutex<HashMap<String, u32>>>. Return429 Too Many Requestsafter 100 requests from the same IP. What happens to the mutex when many requests arrive concurrently? - Write a
CompressionMiddlewarethat checks theAccept-Encodingheader forgzip. If present, addContent-Encoding: gzipto the response. (Don't actually compress the body - just set the header to practice the pattern.) - Add a
SecurityHeadersmiddleware that addsX-Content-Type-Options: nosniff,X-Frame-Options: DENY, andStrict-Transport-Securityto every response. - Make the middleware stack configurable: read a
[middleware]section from the TOML config file that enables or disables each middleware. For example,cors = true,auth = false. - Write a
RequestIdmiddleware that generates a UUID for each request (useuuidcrate or a simple counter), adds it as anX-Request-Idresponse header, and includes it in the tracing span so all log lines for that request are correlated.
What's Next
Our server now has a composable middleware system: logging, auth, and CORS stack cleanly around the router. Each middleware is independently testable, and the Stack composes them with a single fold.
But our data still lives only in memory. In the next chapter, we add database persistence - connecting to SQLite, running async queries with sqlx, and replacing the in-memory Store with real persistence.