Middleware

Middleware

On this page

Middleware sits around the rest of the chain. It can inspect the request on the way in, inspect or replace the response on the way out, wrap the whole call, or decline to call next at all.

use rainier_framework::prelude::*;

pub struct RequireApiKey;

#[async_trait]
impl MiddlewareContract for RequireApiKey {
    async fn handle(&self, request: Request, next: Next) -> Response {
        match request.header("x-api-key") {
            Some("secret") => next.run(request).await,
            _ => Response::new(StatusCode::UNAUTHORIZED),
        }
    }

    fn name(&self) -> &'static str {
        "RequireApiKey"
    }
}

The prelude imports the trait as MiddlewareContract, because Middleware is taken by the facade for the registry. Import rainier_framework::middleware::Middleware directly if you prefer the plain name.

name() is a label — it is what route:list prints. Nothing looks middleware up by it, so two stages sharing one is untidy rather than broken. Leaving it out gives you the type's short name, which is usually right.

The onion

flowchart TD
    subgraph global [Global — every request, matched or not]
        direction TB
        G0[HandleCors]
        G1[TrimStrings]
        G2[ConvertEmptyStringsToNull]
    end

    subgraph route [Route — this route only]
        direction TB
        R2[ThrottleRequests]
        R3[Authenticate]
    end

    REQ[Request] --> G0 --> G1 --> G2 --> R2 --> R3 --> H[Handler]
    H --> R3b[Authenticate] --> R2b[ThrottleRequests]
    R2b --> G2b[ConvertEmptyStringsToNull] --> G1b[TrimStrings] --> G0b[HandleCors] --> RES[Response]

    style H fill:#353,stroke:#6a6,color:#fff

Each stage sees the request going in and the response coming out. name() is what route:list prints and what appears in Pipeline::stage_names().

Where middleware is declared

A name-based middleware kernel needs three maps: a global list, an alias → class map, and named groups of aliases. Rainier has the first. The other two exist only because a dynamic language cannot put a class in a route file and have the router mean it.

// src/app/http/kernel.rs

/// Global — runs on every request, outermost first.
pub fn register(registry: &MiddlewareRegistry) {
    registry.global(RequestIdMiddleware::new());
    registry.global((TrimStrings::new(), ConvertEmptyStringsToNull));
}

/// A group — a function returning a stack, not a name in a map.
pub fn web() -> MiddlewareStack {
    groups::web().with(RequireApiKey)
}
Rainier::new(".").with_middleware(kernel::register)
RainierThe name-based equivalent
registry.global(m)a global middleware array
.middleware(m) on a routean alias registered in a kernel map, named at the route
pub fn web() -> MiddlewareStacka named group in a config array
ThrottleRequests::per_minute(60)parameters packed into a string, 'throttle:60,1'
.without_middleware::<T>()an opt-out by name

Why values and not names

router.get("/me", me).middleware(Authenticate::<User>::resolved_with_guard("api"));

The name-based version is ->middleware('auth:api'), looked up at runtime. That lookup map is the only thing standing between a typo and a route that serves unguarded, and a dynamic language has no better option.

.middleware("athu").middleware(Authenticate…)
The typoboots; the route is publicdoes not compile
Renaming the middlewareevery route silently breaksevery route is a compile error, named
Deleting itsamesame
"What is in web?"grep the kernelgo to definition
A group with a parameterparse it back out of a stringapi(60)

But doesn't the router now depend on everything?

No, and this was the original reason for the names — worth stating because it sounds decisive and is not.

Route::middleware takes impl IntoMiddlewareStack, which bottoms out at dyn Middleware. That trait lives in rainier-middleware, which rainier-routing already depends on. The application names Authenticate<User>, because the application is the crate that knows both the guard and the user model.

flowchart LR
    APP["your app<br/><i>names Authenticate&lt;User&gt;</i>"] --> AUTH[rainier-auth]
    APP --> ROUTING[rainier-routing]
    AUTH --> MW[rainier-middleware<br/><i>defines dyn Middleware</i>]
    ROUTING --> MW

    style APP fill:#2d3748,stroke:#4a5568,color:#fff

The router never names a concrete middleware, so it never needs the crate that defines one. Nothing about that required a string.

Attaching it

Anything that is middleware, a tuple of middleware, or a stack:

// One.
router.get("/", home).middleware(AddHeaders::security_defaults());

// Several, in order.
router.post("/upload", upload)
    .middleware((RequireApiKey, ThrottleRequests::per_minute(5)));

// A group.
router.group(GroupAttributes::new().middleware(kernel::web()), |router| {
    router.get("/dashboard", dashboard);
});

// Chained, which is the same thing.
router.get("/admin", admin)
    .middleware(kernel::web())
    .middleware(RequireRole::Admin);

Group middleware runs outside a route's own, so a group's session wraps whatever the route adds.

Groups are functions

pub fn api(per_minute: u32) -> MiddlewareStack {
    MiddlewareStack::new().with(ThrottleRequests::per_minute(per_minute))
}

pub fn api_authenticated() -> MiddlewareStack {
    api(60).with_stack(auth("api"))
}

Note what is not in there. CORS belongs on the global stack, and a group is the one place it cannot work — see HandleCors.

Nesting is with_stack. There is no cycle check because a cycle is not expressible: a function that calls itself into a value recurses forever, and that is a mistake you cannot make by writing one line in a config array.

Extending beats replacing. The classic config edit that overwrites a framework group wholesale — ['web'] = ['secure-headers'] — silently drops the session middleware and every page in the group loses its session, with nothing failing. Write your web() in terms of the framework's:

pub fn web() -> MiddlewareStack {
    rainier_framework::groups::web().with(RequestIdMiddleware::new())
}

Rainier ships groups::web(), groups::api(), groups::api_throttled(n), groups::session(), groups::normalise_input() and groups::trust_local_proxies().

Middleware that needs the container

Routes are declared before the container is populated, so an authentication guard cannot be handed its AuthManager at declaration time. This is the one thing the alias-and-factory indirection genuinely bought.

resolved buys it without a name:

MiddlewareStack::new()
    .resolved(|auth: Arc<AuthManager<User>>| Authenticate::new(auth))

The closure runs when the router compiles — after the providers, before the first request. A missing binding is a boot failure naming the route, the middleware and the service:

Error: route `GET /api/me`: could not build the `Authenticate` middleware:
nothing is bound for `app::auth::AuthManager<app::models::User>` — register it
in a service provider before resolving

The container arrives as an argument. That is the difference between a builder a test can call and one that panics unless a process-wide slot happens to be filled — the same reason the names went.

deferred is the general form, for middleware built from more than one service.

Opting out

router.post("/webhooks/stripe", stripe)
    .without_middleware::<VerifyCsrfToken>();

By type, because that is the only identity a value has. It matches the concrete type, so excluding ThrottleRequests drops every rate limiter the group applied rather than one particular configuration of it. Where that is too blunt, do not put it in the group.

Controller middleware

A resource controller can guard its own actions:

impl ResourceController for PostController {
    async fn index(&self, request: Request) -> Response { … }
    async fn destroy(&self, request: Request) -> Response { … }

    fn middleware(&self) -> ControllerMiddleware {
        ControllerMiddleware::new()
            .except(
                [ResourceAction::Index, ResourceAction::Show],
                Authenticate::<User>::resolved(),
            )
            .only([ResourceAction::Destroy], RequireRole::Admin)
    }
}
MethodApplies to
always(m)every action
only([actions], m)just those
except([actions], m)everything else

Prefer except. An action added next year arrives guarded, where an only list leaves it public until someone remembers to add it.

Controller middleware runs inside the route's group and outside the action, so a group's session still wraps the controller's authorisation check.

The built-ins

MiddlewareConstruct itWhat it does
TrimStringsTrimStrings::new()trims whitespace from every string input
ConvertEmptyStringsToNullConvertEmptyStringsToNull"" becomes null
AddHeadersAddHeaders::security_defaults()adds fixed response headers
HandleCorsHandleCors::for_origins([…])CORS, including preflight — global only
ThrottleRequestsThrottleRequests::per_minute(60)rate limiting
TrustProxiesgroups::trust_local_proxies()believe X-Forwarded-For, from proxies you name
StartSessiongroups::session()load and persist the session
AuthenticateAuthenticate::<User>::resolved()see Authentication
RedirectIfAuthenticatedRedirectIfAuthenticated::new(auth, "/")bounce a logged-in user off /login
TimeoutTimeout::seconds(30)cancel a handler that overruns, answering 408
CompressCompress::new()gzip a text response the client asked for
MethodOverrideMethodOverride::new()let an HTML form spell PUT/PATCH/DELETE

The two input normalisers are global by default; throttling and authentication are opt-in per route, because applying them everywhere by default is nearly always wrong. Timeout and Compress are global when configured on and absent otherwise.

CORS is opt-in but not per route: an application registers it globally or not at all. There is no default policy because there is no origin list the framework could guess.

TrimStrings and ConvertEmptyStringsToNull

These rewrite the parsed input, which is why request.input(…) sees the normalised value and validation does too. An empty form field arriving as "" and being treated as "present but empty" is a bug factory; turning it into null makes Rule::Required behave the way you expect.

registry.global(TrimStrings::new().except(["password"]));

Exempt anything where leading or trailing whitespace is meaningful.

AddHeaders

AddHeaders::security_defaults()
AddHeaders::new().with("x-frame-options", "DENY")

HandleCors

// app/http/kernel.rs — global, never on a route or a group.
registry.global(
    HandleCors::for_origins(["https://app.example.com", "http://localhost:5173"])
        .allow_methods(["GET", "POST"])
        .allow_headers(["content-type", "authorization"])
        .expose_headers(["x-request-id"])
        .allow_credentials(true)
        .max_age(600),
);

Register it globally. This is the one placement that decides whether the policy works at all, and the wrong one fails silently.

A browser asks permission before sending anything that is not a "simple" request — everything carrying Authorization, and every POST of JSON — and it asks with OPTIONS against the same path. No route declares OPTIONS, so the router matches the path, rejects the method and answers 405 before entering the route's pipeline. Middleware on a route or a group is that pipeline, so it never runs and the preflight is refused with no CORS headers on the refusal.

What still works is exactly the requests that needed no preflight, which is why a group-mounted policy looks fine: a plain GET is decorated correctly and the entire authenticated surface is unreachable from a browser. Global middleware wraps the router rather than sitting inside it, so it answers the preflight — and puts the headers on 404s and 405s, without which a browser reports a mistyped URL as a CORS failure.

The bootstrap warns at boot if it finds a policy on a route pipeline and none globally.

* is not the permissive end of this

Built asAnswersMeans
any_origin()Access-Control-Allow-Origin: *public reads from anywhere; no browser client can authenticate
any_origin().allow_credentials(true)the caller's origin, reflectedevery page on the internet may call this as whoever is logged in
for_origins([…]).allow_credentials(true)the caller's origin if listedwhat an application usually means

A browser will not attach a cookie to a cross-origin request whose response omits Access-Control-Allow-Credentials, and will not accept that header beside Access-Control-Allow-Origin: *. The origin list and the credentials flag are therefore one decision: naming origins is what makes credentials possible, and credentials are what make the cookie arrive.

Row two does not fail. allowed_origin_for reflects the caller's origin rather than answering *, so the browser is satisfied and the policy works — for everybody, which is the problem. reflects_every_origin() is how a test asserts you are not in that row.

ThrottleRequests

ThrottleRequests::per_minute(60)
ThrottleRequests::per_hour(1000)
ThrottleRequests::new(10, Duration::from_secs(1))

A throttled request is a 429 with Retry-After, and that header survives error re-rendering.

What it counts against

The default key is the bearer token if there is one and the client IP otherwise — which is why TrustProxies matters, because without it configured correctly a client can forge the very thing the limiter counts.

That default is the wrong key for a login form. There is no token yet, so every attempt counts against an address, and:

  • an attacker spraying one password across ten thousand accounts from a botnet never trips it;
  • a whole office behind one NAT locks itself out.

Key on what was submitted instead, which limits attempts per account — the thing actually being protected:

ThrottleRequests::per_minute(5)
    .named("login")
    .keyed_by(|request| request.input("email"))

Returning None from keyed_by means do not count this request: a login with no email in it is a validation failure, and answering 429 would say something untrue.

Name every limiter

Two limiters on one route with no names count the same request twice against the same key, so a 5/min and a 100/hour become a 5/min that also spends the hourly allowance. A name namespaces the keys, and it is also what keeps /login and /password/reset from sharing an allowance when both are keyed by email.

Where the counters live

A bare ThrottleRequests counts in its own process. Behind a load balancer that means five replicas each enforce "five a minute" separately, and the effective limit is twenty-five.

For a page-view limiter that is a rounding error. For a credential limiter it is the difference between a control and a decoration:

router.post("/login", login).middleware(limits::shared(
    ThrottleRequests::per_minute(5)
        .named("login")
        .keyed_by(|request| request.input("email")),
));

limits::shared counts in the store the bootstrap bound from CACHE_DRIVER — the same place locks live, so a deployment decides where its shared state is once. The bootstrap warns at boot when it finds a throttled route counting per-process, naming the routes.

The counter itself is a port, not the cache: a deployment can put its limits in a dedicated service or a database table without the middleware crate learning about either.

It fails open

If the counter is unreachable the request is allowed, and the failure is logged. A Redis outage that turned every request into a 429 would be a much larger incident than the one the limiter prevents, and it would take the login page down with it.

Fixed windows

One integer and one expiry per key, rather than a timestamp per request — the difference between a limiter that costs nothing and one that is its own scaling problem. The trade is a boundary effect: a caller can spend a full window's allowance either side of a reset.

TrustProxies

router.get("/api/x", x)
    .middleware((TrustProxies::these(["10.0.0.0/8"]), ThrottleRequests::per_minute(60)));
ConstructTrusts
groups::trust_local_proxies()loopback and the private ranges — where a proxy usually is
TrustProxies::these(["10.0.0.0/8"])exactly what you name
TrustProxies::all()whoever connected
TrustProxies::all().header("CF-Connecting-IP")…reading a different header

Order it before anything that reads the client IP — the throttle, an audit log. It rewrites ClientIp, and a middleware that already ran has seen the old one.

X-Forwarded-For is appended to by every hop, so it reads client, proxy1, proxy2. Rainier walks it from the right, skipping addresses that are themselves trusted proxies; the first untrusted one is the client.

Taking the leftmost entry instead — the obvious reading of "the original client" — is what makes this spoofable. The left of the chain is whatever the client sent, and a client can send anything:

X-Forwarded-For: 1.2.3.4, 203.0.113.9, 10.0.0.5
                 ^ forged  ^ real       ^ our proxy

Only entries your own proxies appended are worth believing, and those are on the right.

TrustProxies::all() names no addresses, so it cannot recognise a hop to skip and simply takes the rightmost entry — correct when a single proxy is the only thing that can reach the port, and wrong if the port is open to the internet.

Timeout

router.post("/import", import).middleware(Timeout::seconds(300));

A ceiling on how long a handler may take, answering 408 when it overruns. Without one, a handler that never returns holds its connection, its task and whatever it borrowed for as long as the process lives — and enough of those is a service that has stopped answering everything, with nothing in the log about the one endpoint that hung.

Configure it globally rather than per route with server.request_timeout_secs; 0 turns it off, which is the default, because the right ceiling is a fact about your application and a wrong one cancels work that was going to succeed. When set, it goes first in the global stack — a timeout that sits inside the throttle cannot cancel time spent in the throttle.

SERVER_REQUEST_TIMEOUT=30

Two things it does not do, both worth knowing before relying on it:

Cancelling is not undoing. At the deadline the handler's future is dropped, which stops the part that had not run yet. Work already handed to somebody else keeps going: a query in flight was already sent and the row will be written, an HTTP request already issued reaches the other service, a job already pushed will be worked. This bounds your latency. A handler that must be all-or-nothing needs a transaction, not a timeout.

It can only interrupt at an await. A handler that blocks the thread — a long CPU loop, a synchronous file read, std::thread::sleep — never yields, so the timer never fires and the runtime worker stays blocked. Move that work to spawn_blocking; no middleware can save you from it.

It bounds the handler, not the response body, so a streaming or server-sent-events route is unaffected: it returns its response immediately and streams afterwards.

Compress

registry.global(Compress::new());

Or server.compression = true. Off by default, because the usual deployment has nginx or a CDN in front and compressing twice is CPU spent to produce the same bytes — turn it on when Rainier is what clients talk to.

A JSON list of two hundred records is mostly repeated key names, and gzip takes it to roughly a tenth. What it deliberately leaves alone:

Why
bodies under min_size (1 KiB)the gzip header and trailer cost more than the saving
image/*, video/*, zip, woff2, PDFalready compressed; deflate makes them very slightly larger
streaming bodiesbuffering an SSE response to compress it would hold every event until the stream ended
anything with a content-encodingsomebody has already decided

Vary: accept-encoding goes on every response that could have been compressed, whether or not it was. Without it a shared cache can hand a gzipped body to a client that did not ask for one — which reads, at the far end, as a corrupt response from an endpoint that works fine when tested directly.

Compress::new().min_size(4096).level(9)     // 0 none … 9 smallest, default 6

gzip and deflate, negotiated from accept-encoding with q-values honoured, gzip winning a tie. Compression runs inline on the runtime thread: a few hundred kilobytes is a millisecond or two, and a route returning megabytes should raise min_size or not use this.

MethodOverride

registry.global(MethodOverride::new());
<form method="post" action="/posts/7">
  <input type="hidden" name="_method" value="DELETE">
</form>

A browser form can send GET and POST and nothing else. The hidden _method field is the long-standing server-side convention for the rest, and this converts it — which is what lets a server-rendered application have a REST-shaped route table without a line of JavaScript.

Off by default and deliberately so. A JSON API has no use for it, and a rewrite nobody needs is one that can surprise somebody: anything upstream that made a decision by method — a WAF rule, an audit log, a proxy that only forwards POST to this path — made it on the original.

It only ever upgrades a POST, and only to PUT, PATCH or DELETE. A GET carrying ?_method=DELETE is a link somebody crafted, and following it would make every crawler a hazard. Anything else in the field is ignored rather than refused: a 400 would be a stricter contract than the form ever agreed to.

MethodOverride::new().trusting_the_header() also honours X-HTTP-Method-Override, for a client behind something that will not forward a PATCH. Separate from the default because a header is easier to attach by accident: an intermediary adding it to every request would rewrite every POST in the application.

Order it before the router, which a global stage already is — the route has otherwise been chosen for the old method.

Writing one

use std::sync::atomic::{AtomicU64, Ordering};
use rainier_framework::middleware::{Middleware, Next};
use rainier_framework::prelude::*;

pub struct RequestId(pub String);

#[derive(Default)]
pub struct RequestIdMiddleware {
    counter: AtomicU64,
}

#[async_trait]
impl Middleware for RequestIdMiddleware {
    async fn handle(&self, mut request: Request, next: Next) -> Response {
        let id = request
            .header("x-request-id")
            .filter(|incoming| is_safe(incoming))
            .map(str::to_string)
            .unwrap_or_else(|| self.next_id());

        request.extensions_mut().insert(RequestId(id.clone()));
        next.run(request).await.with_header("x-request-id", &id)
    }

    fn name(&self) -> &'static str {
        "RequestId"
    }
}

Three things worth copying from this:

  1. It takes &self, not self. Middleware is shared across every concurrent request, so state must be interior-mutable — AtomicU64, Mutex, DashMap. The compiler will tell you.
  2. It honours an incoming value. A request that crossed a proxy keeps one id end to end, which is the entire point of having one.
  3. It validates before echoing. An id echoed into a response header must not carry anything that could break out of it, and an unbounded one is a cheap way to bloat every log line.

Handlers read what it left behind through extensions:

let id = request.extension::<RequestId>().map(|id| id.0.clone());

Short-circuiting

Not calling next is how a guard rejects:

async fn handle(&self, request: Request, next: Next) -> Response {
    if !allowed(&request) {
        return Response::new(StatusCode::FORBIDDEN);
    }
    next.run(request).await
}

next.remaining() tells you how many stages are left, which is occasionally useful in diagnostics.

Using a pipeline directly

Pipeline is public, so middleware is testable without a router:

let response = Pipeline::new()
    .through(TrimStrings::new())
    .through_arc(Arc::clone(&generator) as Arc<dyn Middleware>)
    .then(|request: Request| async move { Response::text("ok") })
    .run(request)
    .await;

Use through_arc with a shared Arc when the middleware has state you want to observe across requests — building a fresh one per request restarts its counters, which is a mistake that produces a confusingly passing test.

This page is the framework’s docs/middleware.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.