roadrunner_middleware behaviour (roadrunner v0.6.0)
View SourceContinuation-style middleware for roadrunner handlers.
A middleware wraps the rest of the request pipeline:
-callback call(Request, Next, State) -> Result when
Request :: roadrunner_req:request(),
Next :: fun((Request) -> Result),
State :: state(),
Result :: roadrunner_handler:result().The pipeline (handler at its core) returns {Response, Req2}. Each
middleware sees the same shape and is expected to return it — either
straight from Next or after transforming it.
Each middleware decides:
- pass through unchanged —
Next(Req) - transform the request —
Next(Req#{...}) - short-circuit / halt — return
{Response, Req}without callingNext - wrap the response — let
Next(Req)run, then transform what it returned (status, headers, body) - side effects around the call — log, time, instrument
This shape is deliberately lighter than cowboy's deprecated
(Req, Env) middlewares (which couldn't see the response) and
much lighter than cowboy stream handlers (which split the request
lifecycle into five callbacks). It matches the modern
continuation/decorator pattern used by Plug.Builder, Express.js,
Tower, and Servant.
No direct wire writes from middleware
Middleware code never has access to the underlying socket — the
Request map intentionally excludes any socket reference. To
respond, a middleware must return a Result (either the one
from Next(Req) or its own response triple); there is no reply
escape hatch equivalent to cowboy's mid-flight cowboy_req:reply/4.
This is a feature, not a limitation. Bytes only hit the wire from one place — the conn process — which means:
[roadrunner, request, stop]telemetry fires for every request, with consistent duration and status metadata.- gzip wrapping, response transforms, and
Content-Lengthframing are applied uniformly regardless of which middleware produced the response. - Send errors are handled in one place (
[roadrunner, response, send_failed]telemetry, drain bookkeeping, slot release). - The "halt" pattern is structurally simple: don't call
Next, just return a response. There's no second halt protocol to maintain (compare: an arizona cowboy adapter has to support BOTH stashed redirects AND raw-write-from-middleware to stay backward-compatible with cowboy's permissiveness; the roadrunner adapter only handles the stashed-redirect path).
If you're porting middleware from cowboy that called
cowboy_req:reply/4 directly, replace the call with returning a
response triple — {Status, Headers, Body} — from the middleware,
and the framework writes the bytes.
Where middlewares live
- Listener-level:
roadrunner_listener:start_link(_, #{middlewares => [...]}). These run for every request — single-handler and routed. - Per-route: as the
middlewareskey on a map-shape route entry:#{path => ~"/path", handler => handler_mod, middlewares => [...]}. The tuple shorthands ({Path, Handler}/{Path, Handler, State}) intentionally cannot carry middlewares — use the map form when you want them.
When both are configured, listener middlewares wrap route middlewares which wrap the handler — first in each list runs outermost.
Middleware shape
Each entry in a middlewares list is a Callable, optionally paired with
its State as {Callable, State}. State is the middleware's own
configuration, handed to the third argument of the call. A bare
Callable is shorthand for {Callable, undefined}, the same way a
{Path, Handler} route omits the state a {Path, Handler, State} route
carries.
module()/{module(), State}— the module'scall/3behaviour callback is invoked asMod:call(Req, Next, State).fun((Request, Next, State) -> Result)/{Fun, State}— the fun is invoked directly asFun(Req, Next, State).
State defaults to undefined for the bare forms. The same callable can
appear more than once with different State, e.g.
[{rate_limit, #{rps => 10}}, {rate_limit, #{rps => 100}}].
Middleware State is not the request's state field. Route state
({Path, Handler, State}) is injected onto the request map and read
with roadrunner_req:state/1; middleware State is handed to call/3
as an argument and never touches the request.
Examples
%% Stateless auth check — halt with 401 when missing. Wire it as a
%% bare `fun ?MODULE:auth/3`.
auth(Req, Next, _State) ->
case roadrunner_req:header(~"authorization", Req) of
undefined -> {roadrunner_resp:unauthorized(), Req};
_ -> Next(Req)
end.
%% Around: time the whole request including the response write.
timing(Req, Next, _State) ->
Start = erlang:monotonic_time(millisecond),
Result = Next(Req),
logger:info(#{took_ms => erlang:monotonic_time(millisecond) - Start}),
Result.
%% Stateful: inject a configurable `server` header on every response.
%% Wire it as `{fun ?MODULE:server_header/3, ~"roadrunner"}`.
server_header(Req, Next, Server) ->
{{S, H, B}, Req2} = Next(Req),
{{S, [{~"server", Server} | H], B}, Req2}.
Summary
Types
A single entry in a middlewares list: a Callable, or a
{Callable, State} pair. Callable is either a module implementing
-behaviour(roadrunner_middleware) (its call/3 is invoked) or a
middleware_fun/0 invoked directly. State is the middleware's own
configuration, passed through as the third argument; a bare Callable
defaults it to undefined.
The function shape of a fun-form middleware: it receives the request,
the continuation, and the entry's state/0.
An ordered list of middleware/0 entries.
The continuation passed to a middleware's call/3: a fun that runs
the rest of the pipeline (other middlewares + the inner handler)
and returns the same roadrunner_handler:result/0 shape every
middleware returns.
A middleware entry's per-instance state, passed as the third argument
of call/3. undefined is the default for a bare Callable entry.
Callbacks
The middleware contract. Request is the current request map;
Next is a continuation that runs the rest of the pipeline (other
middlewares + the inner handler) and returns the same
roadrunner_handler:result/0 shape every middleware returns. State
is the entry's configuration (the second element of a {module(), State} entry, or undefined for a bare module() entry).
Types
-type middleware() :: module() | middleware_fun() | {module(), state()} | {middleware_fun(), state()}.
A single entry in a middlewares list: a Callable, or a
{Callable, State} pair. Callable is either a module implementing
-behaviour(roadrunner_middleware) (its call/3 is invoked) or a
middleware_fun/0 invoked directly. State is the middleware's own
configuration, passed through as the third argument; a bare Callable
defaults it to undefined.
-type middleware_fun() :: fun((roadrunner_req:request(), next(), state()) -> roadrunner_handler:result()).
The function shape of a fun-form middleware: it receives the request,
the continuation, and the entry's state/0.
-type middleware_list() :: [middleware()].
An ordered list of middleware/0 entries.
-type next() :: fun((roadrunner_req:request()) -> roadrunner_handler:result()).
The continuation passed to a middleware's call/3: a fun that runs
the rest of the pipeline (other middlewares + the inner handler)
and returns the same roadrunner_handler:result/0 shape every
middleware returns.
-type state() :: undefined | term().
A middleware entry's per-instance state, passed as the third argument
of call/3. undefined is the default for a bare Callable entry.
Callbacks
-callback call(Request :: roadrunner_req:request(), Next :: next(), State :: state()) -> roadrunner_handler:result().
The middleware contract. Request is the current request map;
Next is a continuation that runs the rest of the pipeline (other
middlewares + the inner handler) and returns the same
roadrunner_handler:result/0 shape every middleware returns. State
is the entry's configuration (the second element of a {module(), State} entry, or undefined for a bare module() entry).
The middleware decides whether to:
- pass through unchanged (
Next(Req)), - transform the request (
Next(Req#{...})), - short-circuit (return
{Response, Req}without callingNext), - wrap the response (let
Next(Req)run, then transform what it returned), - run side effects around the call (log, time, instrument).
Functions
-spec compose(middleware_list(), next()) -> next().
Compose a middleware list around a handler call, returning a single
next() fun that runs the full pipeline.
The first middleware in the list runs outermost — it gets the first crack at the request and the last crack at the response. The handler is the innermost call; an empty list returns the handler fun unchanged.