navree

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.

OrderLayerWhat it doesConfigured by
1Request contextRequest id, client IP, tracing span; request log and metrics on the way outhttp.trusted_proxies, http.trust_proxy
2CORSPreflights and CORS headershttp.cors.allowed_origins (none by default: no CORS layer)
3Panic catcherA panic becomes a 500
4Timeout503 after http.timeout_secshttp.timeout_secs (30)
5Security headersnosniff, DENY framing, no-referrer, HSTS and a strict CSPhttp.security_headers (true)
6Compressiongzip or brotlihttp.compression (true)
7Error renderingThe NestJS error format, error mappers, 5xx logs
8Maintenance mode503 while the maintenance file existshttp.maintenance_file, http.maintenance_except
9Per-IP rate limit429 over the limithttp.per_ip_rate_limit (600 per minute)
10Body limit413 over the limithttp.body_limit_bytes (2 MiB)
11Session slotnavree-session's layer, then yours
12AuthContext slotNavree's authentication, then yours
13Uow slotnavree-db's unit of work, then yours
14Router and route pipelinePer-route checks, then the handlerRoute 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.

config/app.toml

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:

src/modules/timing.rs

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:

SlotRunsUse it for
LayerSlot::SessionBefore authenticationWork that doesn't need the user: timing, request rewriting, your own sessions
LayerSlot::AuthContextAfter Navree's authentication, before the unit of workWork that needs the principal: tenancy, impersonation, per-user headers
LayerSlot::UowInside 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 (from navree::contracts) and the ClientIp (from navree::http);
  • from the AuthContext slot inward, the AuthState (from navree::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:

src/modules/tenancy.rs

Rules for global layers

  • Answer errors with AppError. An AppError turned 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. apply must 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 for serve), so do setup work in a provider and read it from the layer.
  • apply can fail. Returning an error stops the boot with an error that names the layer and its module, such as global 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:

Rust

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:

Rust

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):

JSON

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) limit requests 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 than http.timeout_secs. A longer one extends the global timeout for that route.
  • A body limit larger or smaller than http.body_limit_bytes is 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:

Rust

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

Text

Testing middleware

TestClient sends requests through the whole pipeline, your layers included:

Rust

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks