Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Middleware

Middleware runs around your route handlers — before and after — for cross-cutting concerns like logging, CORS, security headers, and auth.

A middleware is a BoxedMiddleware: a function of (Context, Next) that returns Result<Response>. It calls next(ctx) to invoke the rest of the chain (and the handler), or returns early to short-circuit. Middleware is global — it applies to every route, in the order it's added. (For per-route access control, use the authorization guards inside the relevant handler.)

app.use_middleware(ultimo::middleware::builtin::logger());

Built-in middleware

All built-ins live in ultimo::middleware::builtin and return a BoxedMiddleware.

Logger

use ultimo::middleware::builtin::logger;
 
app.use_middleware(logger());

Logs each request and response (method, path, status, duration) via tracing.

CORS

Defaults (allow *, GET/POST, Content-Type):

use ultimo::middleware::builtin::cors;
 
app.use_middleware(cors());

Configured, with the Cors builder:

use ultimo::middleware::builtin::Cors;
 
app.use_middleware(
    Cors::new()
        .allow_origin("https://example.com")
        .allow_methods(vec!["GET", "POST", "PUT", "DELETE"])
        .allow_headers(vec!["Content-Type", "Authorization"])
        .build(),
);

Security headers

Secure defaults (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy):

use ultimo::middleware::builtin::security_headers;
 
app.use_middleware(security_headers());

Or customize with the SecurityHeaders builder (CSP is opt-in — it's off by default because a wrong policy breaks more than it protects):

use ultimo::middleware::builtin::SecurityHeaders;
 
app.use_middleware(
    SecurityHeaders::new()
        .csp("default-src 'self'")
        .frame_options("DENY")
        .referrer_policy("no-referrer")
        .build(),
);

See Security for the full list and defaults.

Compression (requires compression feature)

Automatically compress response bodies with brotli or gzip (pure Rust — no C dependencies). Prefers brotli over gzip when the client accepts both, skips already-encoded responses and binary MIME types, and always sets Vary: Accept-Encoding.

ultimo = { version = "0.6", features = ["compression"] }

Quick start with sensible defaults (brotli + gzip, 1 KB minimum body):

use ultimo::middleware::builtin::compression;
 
app.use_middleware(compression());

Customized with the Compression builder:

use ultimo::middleware::builtin::Compression;
 
app.use_middleware(
    Compression::new()
        .gzip()        // enable gzip
        .brotli()      // enable brotli (preferred over gzip when both on)
        .min_size(512) // only compress bodies ≥ 512 bytes
        .build(),
);
Behavior:
  • Vary: Accept-Encoding is always set (required for correct caching).
  • Brotli is chosen first if the client sends Accept-Encoding: br (or br, gzip).
  • Responses whose Content-Type starts with image/, audio/, video/, or font/woff, or whose type is an already-compressed container (application/zip, application/gzip, etc.) are passed through unchanged.
  • If the response already carries Content-Encoding, the middleware skips it — no double-compression.
  • Bodies smaller than min_size (default: 1024 bytes) are not compressed because the overhead can exceed the saving.

Mount compression before static-file serving so text assets (HTML, JS, CSS) are compressed on the fly:

use ultimo::middleware::builtin::compression;
 
app.use_middleware(compression());
app.serve_static("/assets", "./dist/assets");

Server identity headers

use ultimo::middleware::builtin::{powered_by, server_headers};
 
app.use_middleware(powered_by());                 // X-Powered-By: Ultimo
app.use_middleware(server_headers("MyApp", true)); // Server: MyApp/<version>

Writing custom middleware

A custom middleware is a function returning BoxedMiddleware. The closure takes (Context, Next) and returns a pinned, boxed future resolving to Result<Response>. Call next(ctx).await to continue the chain.

use std::sync::Arc;
use std::time::Instant;
use hyper::header::{HeaderName, HeaderValue};
use ultimo::middleware::{BoxedMiddleware, Next};
use ultimo::prelude::*;
 
/// Add an `X-Response-Time` header measured around the handler.
fn timing() -> BoxedMiddleware {
    Arc::new(|ctx: Context, next: Next| {
        Box::pin(async move {
            let start = Instant::now();
            // `next(ctx)` runs the rest of the chain and returns the Response.
            let mut res = next(ctx).await?;
            let ms = start.elapsed().as_millis();
            res.headers_mut().insert(
                HeaderName::from_static("x-response-time"),
                HeaderValue::from_str(&format!("{ms}ms")).unwrap(),
            );
            Ok(res)
        })
    })
}
 
app.use_middleware(timing());

Key points:

  • The closure signature is Fn(Context, Next) -> Pin<Box<dyn Future<Output = Result<Response>> + Send>> — wrap the body in Box::pin(async move { … }).
  • next is called as next(ctx) (it consumes the context) and yields Result<Response>.
  • To modify the response, capture it from next(ctx).await? and mutate it (e.g. res.headers_mut()), then return Ok(res).

Short-circuiting

Return without calling next to stop the chain early. Returning an Err short-circuits with the matching status (e.g. Unauthorized → 401); the framework turns it into a JSON error response.

use std::sync::Arc;
use ultimo::middleware::{BoxedMiddleware, Next};
use ultimo::prelude::*;
 
/// Reject requests without a matching API key (illustrative — for real auth use
/// the built-in JWT / API-key middleware below).
fn require_api_key(expected: &'static str) -> BoxedMiddleware {
    Arc::new(move |ctx: Context, next: Next| {
        Box::pin(async move {
            match ctx.req.header("x-api-key") {
                Some(key) if key == expected => next(ctx).await,
                _ => Err(UltimoError::Unauthorized("invalid API key".into())),
            }
        })
    })
}

ctx.req.header(name) returns Option<String> (lookup is case-insensitive).

Authentication & authorization

Don't hand-roll auth — Ultimo ships it. Mount one of the built-in auth middlewares and enforce access in your handlers with the guards:

use ultimo::auth::jwt::Jwt;
use ultimo::prelude::*;
 
// Verify JWTs and attach the caller to the Context.
app.use_middleware(Jwt::hs256(b"super-secret-key").build());
 
app.get("/admin", |ctx: Context| async move {
    ctx.require_scope("admin").await?;   // 401 if unauthenticated, 403 if missing
    ctx.json(json!({ "ok": true })).await
});

See JWT, API Keys, and Authorization.

Sharing data between middleware and handlers

The Context carries a small string key/value store, written and read with async set/get:

use std::sync::Arc;
use ultimo::middleware::{BoxedMiddleware, Next};
use ultimo::prelude::*;
 
fn request_id() -> BoxedMiddleware {
    Arc::new(|ctx: Context, next: Next| {
        Box::pin(async move {
            // (generate however you like; a counter/uuid/etc.)
            ctx.set("request_id", "req-123").await;
            next(ctx).await
        })
    })
}
 
app.get("/whoami", |ctx: Context| async move {
    let id = ctx.get("request_id").await.unwrap_or_default();
    ctx.json(json!({ "request_id": id })).await
});

The store holds String values. For structured data, serialize it (e.g. with serde_json::to_string) before set, or attach it via a feature that owns its own typed slot (the jwt/api-key features expose ctx.jwt_claims() / ctx.api_key()).

Error handling in middleware

Wrap next(ctx) to observe or transform errors:

use std::sync::Arc;
use ultimo::middleware::{BoxedMiddleware, Next};
use ultimo::prelude::*;
 
fn catch_errors() -> BoxedMiddleware {
    Arc::new(|ctx: Context, next: Next| {
        Box::pin(async move {
            match next(ctx).await {
                Ok(res) => Ok(res),
                Err(err) => {
                    tracing::error!("request failed: {err}");
                    Err(err) // or map to a different UltimoError
                }
            }
        })
    })
}

Order

Middleware runs in registration order, wrapping the handler:

use ultimo::middleware::builtin::{logger, cors, security_headers};
 
app.use_middleware(logger());           // outermost: logs everything
app.use_middleware(security_headers()); // adds hardened headers
app.use_middleware(cors());             // CORS handling
 
// Flow: logger → security_headers → cors → handler → cors → security_headers → logger

Complete example

use std::sync::Arc;
use std::time::Instant;
use hyper::header::{HeaderName, HeaderValue};
use ultimo::middleware::builtin::{cors, logger, security_headers};
use ultimo::middleware::{BoxedMiddleware, Next};
use ultimo::prelude::*;
 
fn timing() -> BoxedMiddleware {
    Arc::new(|ctx: Context, next: Next| {
        Box::pin(async move {
            let start = Instant::now();
            let mut res = next(ctx).await?;
            res.headers_mut().insert(
                HeaderName::from_static("x-response-time"),
                HeaderValue::from_str(&format!("{}ms", start.elapsed().as_millis()))
                    .unwrap(),
            );
            Ok(res)
        })
    })
}
 
#[tokio::main]
async fn main() -> Result<()> {
    let mut app = Ultimo::new_without_defaults();
 
    app.use_middleware(logger());
    app.use_middleware(security_headers());
    app.use_middleware(cors());
    app.use_middleware(timing());
 
    app.get("/", |ctx: Context| async move {
        ctx.json(json!({ "message": "Hello!" })).await
    });
 
    app.listen("127.0.0.1:3000").await
}

Best practices

  • Keep each middleware focused — one concern per layer.
  • Order matters — put logging outermost, then security/CORS, then app-specific layers.
  • Prefer the built-ins for auth, CORS, and headers rather than re-implementing them.
  • Return errors, don't panic — an Err(UltimoError::…) becomes a proper HTTP error response.