Lesson 14 of 30
Middleware with Tower and tower-http
Middleware wraps a service and runs code before and after it. Axum builds on Tower, so every Axum middleware is a Tower Layer. This lesson covers the two Tower traits, layer ordering, from_fn, and the tower-http layers for request ids, tracing, CORS, body limits, timeouts, and compression.
| Term | Meaning |
|---|---|
Service |
Tower trait for an asynchronous function from a request to a response |
Layer |
Tower trait that takes a service and returns a wrapped service |
ServiceBuilder |
Stacks several layers in the order the request travels |
| Request id | Unique identifier attached to a request so logs from all components can be joined |
Project setup
Cargo.toml
[package]
name = "storefront-middleware"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util", "timeout"] }
tower-http = { version = "0.6", features = ["trace", "cors", "limit", "compression-gzip", "request-id", "propagate-header"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde_json = "1"Each tower-http layer is behind a Cargo feature, so the list names the ones this lesson uses. The tower features util and timeout provide ServiceBuilder and the timeout layer.
Example 1: Layer ordering
Layers wrap the service like nested boxes. With .layer(...) chained on a Router, the last layer added is the outermost: it sees the request first and the response last. ServiceBuilder lists layers in request order, so the first one listed is the outermost.
The program below registers the same two layers both ways.
src/main.rs
use axum::{
extract::Request,
middleware::{from_fn, Next},
response::Response,
routing::get,
Router,
};
use tower::ServiceBuilder;
async fn layer_a(request: Request, next: Next) -> Response {
println!("A in");
let response = next.run(request).await;
println!("A out");
response
}
async fn layer_b(request: Request, next: Next) -> Response {
println!("B in");
let response = next.run(request).await;
println!("B out");
response
}
async fn handler() -> &'static str {
println!("handler");
"ok"
}
#[tokio::main]
async fn main() {
let chained = Router::new()
.route("/chained", get(handler))
.layer(from_fn(layer_a))
.layer(from_fn(layer_b));
let built = Router::new().route("/built", get(handler)).layer(
ServiceBuilder::new()
.layer(from_fn(layer_b))
.layer(from_fn(layer_a)),
);
let app = chained.merge(built);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}cargo run
curl -s -w '\n' localhost:3000/chained
curl -s -w '\n' localhost:3000/builtok
okThe server prints this for each request:
B in
A in
handler
A out
B outRouter::layerapplies to the routes added before the call. Routes added later are not covered.Router::route_layerapplies only to matched routes, so an unknown path returns 404 without running the layer.
Placement rules that follow from the ordering:
| Concern | Placement | Reason |
|---|---|---|
| Request id | Outermost | Every later layer and log line can use it |
| Tracing | Inside request id | Spans include the id and cover the inner layers |
| CORS | Outside authentication | Preflight OPTIONS requests carry no credentials |
| Timeout | Outside authentication and handlers | The limit covers slow lookups and slow handlers |
| Compression | Inside tracing, outside handlers | It compresses the final body |
Example 2: A hand-written Layer and Service
A Service<Request> has Response, Error, and Future associated types and two methods. poll_ready reports whether the service can accept a request, which lets a service apply backpressure. call takes the request and returns the future for the response. A Layer is a factory: layer receives the inner service and returns the wrapper.
This middleware prints the method and path, then returns the inner future unchanged.
src/log_layer.rs
use axum::http::Request;
use std::task::{Context, Poll};
use tower::{Layer, Service};
#[derive(Clone)]
pub struct LogLayer;
impl<S> Layer<S> for LogLayer {
type Service = LogService<S>;
fn layer(&self, inner: S) -> Self::Service {
LogService { inner }
}
}
#[derive(Clone)]
pub struct LogService<S> {
inner: S,
}
impl<S, B> Service<Request<B>> for LogService<S>
where
S: Service<Request<B>>,
{
type Response = S::Response;
type Error = S::Error;
type Future = S::Future;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, request: Request<B>) -> Self::Future {
println!("{} {}", request.method(), request.uri().path());
self.inner.call(request)
}
}Code that must run after the response needs its own future type, which is why most middleware uses from_fn (next example) or comes from tower-http. Implement Service and Layer directly when publishing a reusable layer or when the middleware must be generic over the inner service and error types.
Example 3: Middleware with from_fn
axum::middleware::from_fn turns an async function into a layer. The function receives the request and a Next value that calls the rest of the stack. Code before next.run(request) runs on the way in, and code after runs on the way out. Returning without calling next.run short-circuits the request.
src/middleware.rs
use crate::AppState;
use axum::{
extract::{Request, State},
http::{HeaderValue, StatusCode},
middleware::Next,
response::Response,
};
#[derive(Clone)]
pub struct RequestContext {
pub request_id: String,
}
pub async fn require_api_key(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let supplied = request.headers().get("x-api-key").and_then(|v| v.to_str().ok());
if supplied != Some(state.api_key.as_str()) {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(request).await)
}
pub async fn attach_context(mut request: Request, next: Next) -> Response {
let request_id = request
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("none")
.to_string();
request.extensions_mut().insert(RequestContext { request_id });
next.run(request).await
}
pub async fn add_server_header(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.insert("x-served-by", HeaderValue::from_static("storefront"));
response
}require_api_keyshort-circuits with 401. It usesfrom_fn_with_stateto read the application state.attach_contextstores a typed value in the request extensions, a map keyed by type. A handler reads it withExtension<RequestContext>. If the layer is missing, the extractor fails at request time with a 500, not at compile time.add_server_headerchanges the response after the handler ran.
Use an extractor when a value can be computed from one request's parts, and an extension when a layer computes it.
Example 4: The tower-http layers
The stack in the complete example uses these layers, from outermost to innermost:
SetRequestIdLayeradds anx-request-idheader when the request has none, generating a UUID withMakeRequestUuid. A caller-supplied id is kept.TraceLayer::new_for_httpcreates atracingspan per request and logs start and finish. Observability extends it.PropagateRequestIdLayercopies the id to the response, so a client can quote it in a support request.CorsLayeranswers browser preflight requests and adds CORS headers.HandleErrorLayerwithtower::timeout::TimeoutLayerlimits each request to one second.RequestBodyLimitLayerrejects bodies over the limit.CompressionLayercompresses response bodies with a scheme listed inAccept-Encoding.
An id supplied by an external client is only a label and may contain any text. Generate a fresh one at the edge when it is written into a system that parses it.
Example 5: CORS, limits, and timeouts
- CORS (cross-origin resource sharing) lets a browser page on one origin read responses from another. For requests with custom headers,
PUT, or JSON bodies, the browser first sends a preflightOPTIONSrequest. CORS is enforced by browsers only and does not protect an API fromcurlor other servers. A wildcard origin cannot be combined with cookies. Service-to-service calls do not need CORS. - Body limits: Axum's
Json,Form,Bytes, andStringextractors apply a 2 MB default.DefaultBodyLimitchanges that limit for the extractors.RequestBodyLimitLayerenforces a cap on the body itself and answers 413 when theContent-Lengthheader exceeds it. Limits on headers, connections, and slow uploads belong at the gateway. - Fallible layers:
Router::layerrequires an infallible service, because Axum must turn every failure into a response.tower::timeout::TimeoutLayerreturns anElapsederror, soHandleErrorLayersits outside it and maps the error to a response. Thetower-httptimeout layer produces a response itself. This lesson uses thetowerlayer withHandleErrorLayer.
Complete example
File tree:
storefront-middleware/
Cargo.toml
src/
main.rs
log_layer.rs
middleware.rsCargo.toml
[package]
name = "storefront-middleware"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.5", features = ["util", "timeout"] }
tower-http = { version = "0.6", features = ["trace", "cors", "limit", "compression-gzip", "request-id", "propagate-header"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde_json = "1"src/log_layer.rs
use axum::http::Request;
use std::task::{Context, Poll};
use tower::{Layer, Service};
#[derive(Clone)]
pub struct LogLayer;
impl<S> Layer<S> for LogLayer {
type Service = LogService<S>;
fn layer(&self, inner: S) -> Self::Service {
LogService { inner }
}
}
#[derive(Clone)]
pub struct LogService<S> {
inner: S,
}
impl<S, B> Service<Request<B>> for LogService<S>
where
S: Service<Request<B>>,
{
type Response = S::Response;
type Error = S::Error;
type Future = S::Future;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, request: Request<B>) -> Self::Future {
println!("{} {}", request.method(), request.uri().path());
self.inner.call(request)
}
}src/middleware.rs
use crate::AppState;
use axum::{
extract::{Request, State},
http::{HeaderValue, StatusCode},
middleware::Next,
response::Response,
};
#[derive(Clone)]
pub struct RequestContext {
pub request_id: String,
}
pub async fn require_api_key(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let supplied = request.headers().get("x-api-key").and_then(|v| v.to_str().ok());
if supplied != Some(state.api_key.as_str()) {
return Err(StatusCode::UNAUTHORIZED);
}
Ok(next.run(request).await)
}
pub async fn attach_context(mut request: Request, next: Next) -> Response {
let request_id = request
.headers()
.get("x-request-id")
.and_then(|v| v.to_str().ok())
.unwrap_or("none")
.to_string();
request.extensions_mut().insert(RequestContext { request_id });
next.run(request).await
}
pub async fn add_server_header(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.insert("x-served-by", HeaderValue::from_static("storefront"));
response
}src/main.rs
/health is public. /orders and /slow require the header x-api-key: secret. The timeout is one second and the body limit is 1024 bytes.
mod log_layer;
mod middleware;
use axum::{
error_handling::HandleErrorLayer,
http::{HeaderName, HeaderValue, Method, StatusCode},
middleware::{from_fn, from_fn_with_state},
routing::get,
BoxError, Extension, Json, Router,
};
use log_layer::LogLayer;
use middleware::{add_server_header, attach_context, require_api_key, RequestContext};
use serde_json::{json, Value};
use std::time::Duration;
use tower::{timeout::TimeoutLayer, ServiceBuilder};
use tower_http::{
compression::CompressionLayer,
cors::CorsLayer,
limit::RequestBodyLimitLayer,
request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer},
trace::TraceLayer,
};
#[derive(Clone)]
pub struct AppState {
pub api_key: String,
}
async fn health() -> &'static str {
"ok"
}
async fn list_orders(Extension(ctx): Extension<RequestContext>) -> Json<Value> {
Json(json!({ "request_id": ctx.request_id, "orders": [] }))
}
async fn create_order(Json(_order): Json<Value>) -> (StatusCode, Json<Value>) {
(StatusCode::CREATED, Json(json!({ "created": true })))
}
async fn slow() -> &'static str {
tokio::time::sleep(Duration::from_secs(3)).await;
"done"
}
async fn handle_timeout(error: BoxError) -> (StatusCode, &'static str) {
if error.is::<tower::timeout::error::Elapsed>() {
(StatusCode::REQUEST_TIMEOUT, "request timed out")
} else {
(StatusCode::INTERNAL_SERVER_ERROR, "internal error")
}
}
#[tokio::main]
async fn main() {
tracing_subscriber::fmt().with_env_filter("info,tower_http=debug").init();
let state = AppState { api_key: "secret".to_string() };
let x_request_id = HeaderName::from_static("x-request-id");
let cors = CorsLayer::new()
.allow_origin("https://shop.example.com".parse::<HeaderValue>().unwrap())
.allow_methods([Method::GET, Method::POST])
.allow_headers([axum::http::header::CONTENT_TYPE, HeaderName::from_static("x-api-key")]);
let protected = Router::new()
.route("/orders", get(list_orders).post(create_order))
.route("/slow", get(slow))
.route_layer(from_fn_with_state(state.clone(), require_api_key));
let app = Router::new()
.route("/health", get(health))
.merge(protected)
.layer(
ServiceBuilder::new()
.layer(SetRequestIdLayer::new(x_request_id.clone(), MakeRequestUuid))
.layer(TraceLayer::new_for_http())
.layer(PropagateRequestIdLayer::new(x_request_id))
.layer(cors)
.layer(HandleErrorLayer::new(handle_timeout))
.layer(TimeoutLayer::new(Duration::from_secs(1)))
.layer(RequestBodyLimitLayer::new(1024))
.layer(CompressionLayer::new())
.layer(from_fn(attach_context))
.layer(from_fn(add_server_header))
.layer(LogLayer),
)
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}Run:
cargo runRequests:
req() { curl -s -w ' [%{http_code}]\n' "$@"; }
req localhost:3000/health
req localhost:3000/orders
req -H 'x-api-key: secret' -H 'x-request-id: demo-1' localhost:3000/orders
curl -s -D - -o /dev/null -H 'x-api-key: secret' localhost:3000/orders | grep -i '^x-served-by'
req -X POST -H 'x-api-key: secret' -H 'content-type: application/json' -d '{"sku":"A1"}' localhost:3000/orders
head -c 2000 /dev/zero | tr '\0' 'a' > big.txt
req -X POST -H 'x-api-key: secret' -H 'content-type: application/json' --data-binary @big.txt localhost:3000/orders
req -H 'x-api-key: secret' localhost:3000/slow
curl -s -D - -o /dev/null -X OPTIONS -H 'Origin: https://shop.example.com' -H 'Access-Control-Request-Method: POST' localhost:3000/orders | grep -i '^access-control-allow-origin'ok [200]
[401]
{"orders":[],"request_id":"demo-1"} [200]
x-served-by: storefront
{"created":true} [201]
[413]
request timed out [408]
access-control-allow-origin: https://shop.example.com- The 401 has an empty body because
require_api_keyreturns a bare status code. - Without
x-request-idin the request, the id in the response and inRequestContextis a generated UUID. - The
slowhandler sleeps three seconds, so the one second timeout answers first.
Summary
- Every Axum middleware is a Tower
Layeraround aService. - With
Router::layer, the last layer added runs first on the request.ServiceBuilderlists layers in request order. from_fnwrites application middleware as an async function takingRequestandNext.tower-httpprovides request id, tracing, CORS, compression, body limits, and timeouts.- Request id goes first, tracing next, CORS before authentication, and timeouts around slow work.
- CORS is browser policy, not access control.
- Fallible Tower layers need
HandleErrorLayerso the error becomes a response. - Extensions are typed and checked at runtime. A missing one is a server error.
