The request pipeline and middleware
The global Tower layers Navree installs and their order, adding your own layers in the pipeline's slots, guards, per-route settings and layers on a single route.
On this page
Introduction
Middleware is code that runs around your handlers: it can read and change the request before the handler, refuse it, and read and change the response after. Navree is built on Axum and Tower, so its middleware is Tower layers, and anything written for Axum (axum::middleware::from_fn, tower-http layers) works.
Navree installs a fixed pipeline of layers for you: request ids, logging, CORS, timeouts, security headers, compression, error rendering, maintenance mode, rate limiting and body limits. On top of that you can:
- add global layers that run for every request, in one of three slots of the pipeline;
- add guards, checks that run on chosen routes before the handler;
- tune per-route settings: a throttle, a timeout, a body limit;
- wrap one route in any Tower layer.
The global pipeline
Every request passes through these layers, outermost first. The request lifecycle page describes each one in detail.
| Order | Layer | What it does | Configured by |
|---|---|---|---|
| 1 | Request context | Request id, client IP, tracing span; request log and metrics on the way out | http.trusted_proxies, http.trust_proxy |
| 2 | CORS | Preflights and CORS headers | http.cors.allowed_origins (none by default: no CORS layer) |
| 3 | Panic catcher | A panic becomes a 500 | |
| 4 | Timeout | 503 after http.timeout_secs | http.timeout_secs (30) |
| 5 | Security headers | nosniff, DENY framing, no-referrer, HSTS and a strict CSP | http.security_headers (true) |
| 6 | Compression | gzip or brotli | http.compression (true) |
| 7 | Error rendering | The NestJS error format, error mappers, 5xx logs | |
| 8 | Maintenance mode | 503 while the maintenance file exists | http.maintenance_file, http.maintenance_except |
| 9 | Per-IP rate limit | 429 over the limit | http.per_ip_rate_limit (600 per minute) |
| 10 | Body limit | 413 over the limit | http.body_limit_bytes (2 MiB) |
| 11 | Session slot | navree-session's layer, then yours | |
| 12 | AuthContext slot | Navree's authentication, then yours | |
| 13 | Uow slot | navree-db's unit of work, then yours | |
| 14 | Router and route pipeline | Per-route checks, then the handler | Route attributes |
Layers 1 to 10 are fixed: you can configure them (and turn some off), but you can't remove them, reorder them or put a layer between them. Your global layers go in the slots.
[http]
timeout_secs = 30
body_limit_bytes = 2097152
per_ip_rate_limit = 600
security_headers = true
compression = true
[http.cors]
allowed_origins = ["http://localhost:3000"]Adding a global layer
A module contributes a global layer with m.http_layer(GlobalLayer::new(slot, name, apply)). apply receives the AppContext and the router, and returns the router wrapped in your layer. This one adds an X-Response-Time header to every response:
use navree::axum::extract::Request;
use navree::axum::http::HeaderValue;
use navree::axum::middleware::{self, Next};
use navree::axum::response::Response;
use navree::http::{GlobalLayer, LayerSlot};
use navree::prelude::*;
/// Adds `X-Response-Time` (milliseconds) to every response.
async fn response_time(request: Request, next: Next) -> Response {
let started = std::time::Instant::now();
let mut response = next.run(request).await;
let millis = started.elapsed().as_millis().to_string();
if let Ok(value) = HeaderValue::from_str(&millis) {
response.headers_mut().insert("x-response-time", value);
}
response
}
#[module(register = Self::wire)]
pub struct TimingModule;
impl TimingModule {
fn wire(&self, m: &mut ModuleBuilder) {
m.http_layer(GlobalLayer::new(
LayerSlot::Session,
"response-time",
|_ctx, router| Ok(router.layer(middleware::from_fn(response_time))),
));
}
}Add TimingModule to the app like any module. The layer wraps every route, and the 404 fallback too.
Choosing a slot
The slot decides where the layer runs relative to authentication and the unit of work. The names say what the framework puts there, but any layer can use any slot:
| Slot | Runs | Use it for |
|---|---|---|
LayerSlot::Session | Before authentication | Work that doesn't need the user: timing, request rewriting, your own sessions |
LayerSlot::AuthContext | After Navree's authentication, before the unit of work | Work that needs the principal: tenancy, impersonation, per-user headers |
LayerSlot::Uow | Inside the request's unit of work (after DatabaseModule's layer) | Work that writes to the database and must commit or roll back with the request |
Within a slot, layers run in module dependency order: the layer of a module that comes first (because others import it, or because the root adds it first) is the outer one. In the AuthContext slot, Navree's authentication layer always runs first; in the Uow slot, your layer runs inside navree-db's when your module comes after DatabaseModule.
What a layer can read
Inside the pipeline, the request's extensions already hold:
- the
AppContext, to resolve providers; - the
RequestId(fromnavree::contracts) and theClientIp(fromnavree::http); - from the
AuthContextslot inward, theAuthState(fromnavree::contracts):AuthState(Some(principal))for a signed-in user,AuthState(None)for a guest; - inside navree-db's unit-of-work layer, the request's
Uow.
The matched route's metadata isn't on the request yet when global layers run (routing comes after them), but it's in the extensions of the response they get back, as Arc<RouteMeta>.
This layer uses the AppContext to resolve Tenants, an app service, and the AuthState to refuse every request from a suspended account:
use navree::axum::extract::Request;
use navree::axum::middleware::{self, Next};
use navree::axum::response::{IntoResponse, Response};
use navree::contracts::AuthState;
use navree::http::{GlobalLayer, LayerSlot};
use navree::prelude::*;
async fn suspended_tenants(ctx: AppContext, request: Request, next: Next) -> Response {
let principal = request
.extensions()
.get::<AuthState>()
.and_then(|state| state.0.clone());
if let Some(principal) = principal {
let tenants = match ctx.get::<Tenants>().await {
Ok(tenants) => tenants,
Err(error) => return AppError::internal(error).into_response(),
};
if tenants.is_suspended(principal).await {
return AppError::forbidden()
.with_detail("This account is suspended.")
.into_response();
}
}
next.run(request).await
}
#[module(providers = [Tenants], register = Self::wire)]
pub struct TenancyModule;
impl TenancyModule {
fn wire(&self, m: &mut ModuleBuilder) {
m.http_layer(GlobalLayer::new(
LayerSlot::AuthContext,
"suspended-tenants",
|ctx, router| {
let ctx = ctx.clone();
Ok(router.layer(middleware::from_fn(move |request: Request, next: Next| {
suspended_tenants(ctx.clone(), request, next)
})))
},
));
}
}Rules for global layers
- Answer errors with
AppError. AnAppErrorturned into a response is rendered in the error format by the error layer, which sits outside the slots, and 5xx errors are logged with the request id. - Only add layers.
applymust return the router it was given, wrapped. The router already has its routes and the single 404 fallback. - No side effects in
apply. It may run more than once per app (at boot, to check the routes, and again forserve), so do setup work in a provider and read it from the layer. applycan fail. Returning an error stops the boot with an error that names the layer and its module, such asglobal layer `broken` of module `ReportsModule` failed: no database configured.
Guards
A guard is a check on chosen routes. It runs in the route pipeline, after authentication and the route's throttle and before authorization and the handler, and it either lets the request through or ends it with an error. Implement navree::http::Guard:
use navree::BoxFuture;
use navree::axum::http::request::Parts;
use navree::http::Guard;
use navree::prelude::*;
/// Lets a request through only with the expected `X-Api-Version`.
#[derive(Debug)]
pub struct RequireApiVersion(pub &'static str);
impl Guard for RequireApiVersion {
fn check<'a>(&'a self, parts: &'a mut Parts) -> BoxFuture<'a, Result<(), AppError>> {
let ok = parts
.headers
.get("x-api-version")
.is_some_and(|value| value == self.0);
Box::pin(async move {
if ok {
Ok(())
} else {
Err(AppError::bad_request(format!(
"This route needs X-Api-Version: {}.",
self.0
)))
}
})
}
}Attach guards with #[guard(expr)], on a route or on the impl block (where it applies to every route). The expression must give an Arc of a guard, and it may use self, the controller, so a guard can be an injected field:
#[controller("/exports", version = "1")]
#[authenticated]
#[guard(Arc::new(RequireApiVersion("2")))]
impl ExportsController {
#[post("/")]
#[throttle(limit = 5, per = "1m")]
#[timeout("2m")]
async fn store(&self) -> NoContent {
NoContent
}
#[get("/download/{file}")]
#[guard(navree::http::ValidSignature::guard())]
async fn download(&self, Path(file): Path<String>) -> String {
file
}
}The impl block's guards run first, then the route's, in the order written. A signed-in request to POST /api/v1/exports without the header gets (a guest gets the 401 first):
{ "statusCode": 400, "message": "This route needs X-Api-Version: 2.", "error": "Bad Request" }Navree ships one guard: ValidSignature, which accepts only URLs signed by the app's UrlSigner and not yet expired (a 401 "Invalid signature." otherwise). Use it for links in emails and for downloads. When a guard refuses with a 403 and the app has an audit trail, the refusal is recorded as access.denied.
A guard doesn't declare who may call a route: a route with only a #[guard] still needs an access attribute. On hand-written routes, add guards with RouteDef::guard(Arc<dyn Guard>).
Per-route settings
Some of the global layers have per-route settings:
#[throttle(limit = 5, per = "1m")]allows each user (each IP for guests)limitrequests per window on that route, on top of the per-IP limit. It may sit on the impl block; a route's own replaces it. See rate limiting.#[timeout("2m")]gives the route its own timeout, shorter or longer thanhttp.timeout_secs. A longer one extends the global timeout for that route.- A body limit larger or smaller than
http.body_limit_bytesis set on a hand-written route with.body_limit(bytes); there's no attribute for it yet. See requests.
A layer on one route
The route attributes have no way to attach an arbitrary Tower layer to one route. To do that, write the controller by hand (writing controllers by hand) and wrap the route's Axum MethodRouter before giving it to RouteDef:
use navree::axum::extract::Request;
use navree::axum::http::{HeaderValue, Method};
use navree::axum::middleware::{self, Next};
use navree::axum::response::Response;
use navree::http::{Controller, RouteDef};
use navree::prelude::*;
/// Forbids caching the response.
async fn no_store(request: Request, next: Next) -> Response {
let mut response = next.run(request).await;
response
.headers_mut()
.insert("cache-control", HeaderValue::from_static("no-store"));
response
}
#[injectable]
pub struct TokensController;
impl Controller for TokensController {
fn prefix(&self) -> &'static str {
"/tokens"
}
fn version(&self) -> Option<&'static str> {
Some("1")
}
fn routes(self: Arc<Self>) -> Vec<RouteDef> {
let issue = navree::axum::routing::post(|| async { Json("a-new-token") })
.layer(middleware::from_fn(no_store));
vec![RouteDef::new(Method::POST, "/", issue).authenticated()]
}
}A layer added this way runs inside the route pipeline: after the access checks, guards and authorization, within the route's timeout, right around the handler. A controller can't mix macro routes and hand-written ones, but a module can register both kinds of controller.
The order, all together
global layers (request context ... body limit)
Session slot navree-session, then your Session layers
AuthContext slot Navree's authentication, then your AuthContext layers
Uow slot navree-db's unit of work, then your Uow layers
router
route pipeline: body length → authentication → throttle → guards → authorization → route timeout
your per-route layers
handlerTesting middleware
TestClient sends requests through the whole pipeline, your layers included:
use navree::testing::TestApp;
#[tokio::test]
async fn every_response_has_a_response_time() {
let app = TestApp::builder().module(TimingModule).build().await;
let response = app
.client()
.get("/api/missing")
.send()
.await
.assert_status(404);
assert!(response.header("x-response-time").is_some());
app.shutdown().await.unwrap();
}