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(),
);Vary: Accept-Encodingis always set (required for correct caching).- Brotli is chosen first if the client sends
Accept-Encoding: br(orbr, gzip). - Responses whose
Content-Typestarts withimage/,audio/,video/, orfont/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 inBox::pin(async move { … }). nextis called asnext(ctx)(it consumes the context) and yieldsResult<Response>.- To modify the response, capture it from
next(ctx).await?and mutate it (e.g.res.headers_mut()), then returnOk(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)returnsOption<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
Stringvalues. For structured data, serialize it (e.g. withserde_json::to_string) beforeset, or attach it via a feature that owns its own typed slot (thejwt/api-keyfeatures exposectx.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 → loggerComplete 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.