Middleware
Add cross-cutting behaviour to any Application by implementing the Middleware trait and stacking layers with .wrap().
The Middleware trait
Middleware is defined in src/middleware/mod.rs:
pub trait Middleware: Send + Sync {
fn handle(
&self,
request: &Request,
connection: &ConnectionInfo,
next: &dyn Application,
) -> Result<Response, String>;
}Call next.execute(request, connection) to pass the request to the next layer (or the inner application). Return a Response directly to short-circuit the chain.
Wrapping an application
.wrap(layer) attaches a middleware layer to any Application. Layers run in registration order — the first .wrap() call is the outermost layer (runs first on the way in, last on the way out):
use rust_web_server::app::App;
use rust_web_server::middleware::{RateLimitLayer, WithMiddleware};
use rust_web_server::core::New;
let app = App::new()
.wrap(RateLimitLayer); // outermost: checked before any routeChain multiple layers:
use rust_web_server::metrics::MetricsLayer;
let app = App::new()
.wrap(RateLimitLayer) // checked first
.wrap(MetricsLayer); // checked secondApp, App::with_state(S) and an already-wrapped WithMiddleware<A> expose the same .wrap() method; for any other Application (e.g. AsyncAppWithState), start with WithMiddleware::new(app).wrap(layer).
Built-in middleware layers
| Layer | Module | Description |
|---|---|---|
RateLimitLayer |
middleware |
Enforces the process-wide sliding-window rate limit; returns 429 when the budget for the client IP is exceeded. Configured via RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS / RWS_CONFIG_RATE_LIMIT_WINDOW_SECS. |
MetricsLayer |
metrics |
Records per-route rws_route_requests_total counters and rws_route_duration_seconds histograms in Prometheus format. |
CacheLayer |
cache |
In-process response cache; caches GET responses by URI. |
OtelLayer |
otel |
Emits OpenTelemetry spans and propagates W3C traceparent context. |
RewriteLayer |
rewrite |
Applies configurable request/response rewrite rules (header set/remove, URI prefix manipulation, response body find-and-replace). |
ReverseProxy |
proxy |
HTTP/1.1 reverse proxy; forwards the request to a backend and returns its response. |
H2ReverseProxy |
proxy |
HTTP/2 upstream proxy (requires http2 feature). |
GrpcProxy |
proxy |
Wraps H2ReverseProxy; only proxies requests with Content-Type: application/grpc*. |
BasicAuthLayer |
auth |
HTTP Basic authentication; calls a user-supplied verifier closure. |
JwtLayer |
auth |
JWT Bearer token verification; rejects requests without a valid token. |
IpFilter |
ip_filter |
Allow-list or deny-list based on client IP address or CIDR range. |
BlocklistLayer |
blocklist |
Runtime-updatable IP blocklist; unlike IpFilter, the list can be modified without restart. |
CanaryLayer |
canary |
Routes a configured percentage of traffic to a canary backend. |
RetryLayer |
circuit_breaker |
Re-dispatches a request whose response status is in the retry_on list, up to max_retries more times (no backoff). |
CsrfLayer |
csrf |
CSRF token validation for state-mutating requests. |
LogLayer |
request_log |
Structured per-request access logging (combined or JSON format). |
MaintenanceLayer |
maintenance |
Returns 503 Service Unavailable when maintenance mode is active. |
RequestIdLayer |
request_id |
Reads or generates a request/correlation ID and echoes it on the request and response (X-Request-Id by default). |
ForwardAuthLayer |
auth::forward |
Delegates the allow/deny decision for every request to an external HTTP auth service. |
Quick usage examples
use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::middleware::RateLimitLayer;
use rust_web_server::metrics::MetricsLayer;
use rust_web_server::ip_filter::IpFilter;
use rust_web_server::auth::JwtLayer;
use rust_web_server::request_log::LogLayer;
let app = App::new()
.wrap(LogLayer)
.wrap(IpFilter::deny(["1.2.3.4", "10.0.0.0/8"]))
.wrap(JwtLayer::new(b"secret".to_vec()))
.wrap(RateLimitLayer)
.wrap(MetricsLayer);Writing a custom middleware layer
Implement the Middleware trait on a unit struct (or a struct carrying configuration):
use rust_web_server::middleware::Middleware;
use rust_web_server::application::Application;
use rust_web_server::request::Request;
use rust_web_server::response::Response;
use rust_web_server::server::ConnectionInfo;
/// Adds an X-Request-Id header to every response.
pub struct RequestIdLayer;
impl Middleware for RequestIdLayer {
fn handle(
&self,
request: &Request,
connection: &ConnectionInfo,
next: &dyn Application,
) -> Result<Response, String> {
// Call into the rest of the chain / inner application
let mut response = next.execute(request, connection)?;
// Mutate the response on the way out
let id = format!("{}-{}", connection.server.ip, connection.server.port);
response.headers.push(rust_web_server::header::Header::new("X-Request-Id", id));
Ok(response)
}
}Register it like any built-in layer:
use rust_web_server::app::App;
use rust_web_server::core::New;
let app = App::new().wrap(RequestIdLayer);Running on the event loop (runs_inline)
The async servers normally hand each request to a blocking thread pool, so a slow handler can't stall other connections. For requests that are quick and never wait — health checks, metrics, small static files — that handoff costs more than the work, so rws runs them directly on the event loop, as nginx does. A request runs inline only if every layer and the inner application say so for that request:
impl Middleware for RequestIdLayer {
fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application)
-> Result<Response, String> { /* … */ }
// Only in-memory work: safe on the event loop.
fn runs_inline(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
true
}
}The default is false, and it must stay false for a layer that can wait —
a database or Redis call, an outbound HTTP request, a lock held across I/O.
A layer that doesn't opt in simply keeps the stack on the blocking pool, so
leaving it out is always safe. The built-in in-memory layers
(RateLimitLayer, CacheLayer, IpFilter, RequestIdLayer, MetricsLayer,
LogLayer, BlocklistLayer, MaintenanceLayer) opt in; user route handlers,
proxy routes and MCP calls never run inline.
Streamed request bodies (accepts_streamed_body)
An application can take a request's body as a stream
(Application::streams_body, see Streaming large uploads).
Through a WithMiddleware stack that happens only if every layer returns
true from accepts_streamed_body — the layer then sees an empty
request.body, so return true only from a layer that never reads it. The
built-in header-only layers do; the default is false.
Passing requests through (is_passthrough)
Some applications can answer a request asynchronously (Application::execute_async,
http2 feature) — the config-driven proxy does, for plain-HTTP routes without
middleware, so it waits for the backend without holding a thread. A middleware
stack can only offer that path if none of its layers needs to run. A layer
that does nothing for a given request — a rate limit that isn't configured, a
filter that doesn't apply — says so with is_passthrough:
fn is_passthrough(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
!self.enabled
}Default false: the request then takes the usual synchronous path through
every layer.
Short-circuit example
Return a response without calling next to block the request:
use rust_web_server::middleware::Middleware;
use rust_web_server::application::Application;
use rust_web_server::error::{AppError, IntoResponse};
use rust_web_server::request::{METHOD, Request};
use rust_web_server::response::Response;
use rust_web_server::server::ConnectionInfo;
/// Rejects all non-GET/HEAD requests to /public/*.
pub struct ReadOnlyPublicLayer;
impl Middleware for ReadOnlyPublicLayer {
fn handle(
&self,
request: &Request,
connection: &ConnectionInfo,
next: &dyn Application,
) -> Result<Response, String> {
let is_write = request.method != METHOD.get && request.method != METHOD.head;
let is_public = request.request_uri.starts_with("/public/");
if is_write && is_public {
return Ok(AppError::Forbidden.into_response());
}
next.execute(request, connection)
}
}Layers push in order: app.wrap(A).wrap(B) runs A before B on the request path and B before A on the response path. Put logging and tracing outermost, authentication next, and business-logic concerns innermost.