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.
A layer that answers a request itself — a proxy forwarding it, without
calling next — can claim the stream with answers_streamed_body (since
18.3.0): the body then streams whatever the layers after it and the
application would say, provided the layers before it accept it.
H2ReverseProxy and GrpcProxy do, for the requests they forward (gRPC
client streaming).
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.