-module(roadrunner_middleware).
-moduledoc """
Continuation-style middleware for roadrunner handlers.
A middleware wraps the rest of the request pipeline:
```erlang
-callback call(Request, Next) -> Result when
Request :: roadrunner_req:request(),
Next :: fun((Request) -> Result),
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 calling
`Next`
- **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-Length` framing
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 `middlewares` key 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 one of:
- `module()` — the module's `call/2` (this behaviour callback) is invoked.
- `fun((Request, Next) -> Result)` — invoked directly.
## Examples
```erlang
%% Auth check — halt with 401 when missing.
auth(Req, Next) ->
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) ->
Start = erlang:monotonic_time(millisecond),
Result = Next(Req),
logger:info(#{took_ms => erlang:monotonic_time(millisecond) - Start}),
Result.
%% Inject a server header on every response.
server_header(Req, Next) ->
{{S, H, B}, Req2} = Next(Req),
{{S, [{~"server", ~"roadrunner"} | H], B}, Req2}.
```
""".
-export([compose/2, build_pipeline/2, compile_pipeline/3]).
-export_type([middleware/0, middleware_list/0, next/0]).
-doc """
The continuation passed to a middleware's `call/2`: a fun that runs
the rest of the pipeline (other middlewares + the inner handler)
and returns the same `t:roadrunner_handler:result/0` shape every
middleware returns.
""".
-type next() :: fun((roadrunner_req:request()) -> roadrunner_handler:result()).
-doc """
A single entry in a `middlewares` list. Either a module that
implements `-behaviour(roadrunner_middleware)` (its `call/2` is
invoked) or a `fun((Request, Next) -> Result)` invoked directly.
""".
-type middleware() ::
module()
| fun((roadrunner_req:request(), next()) -> roadrunner_handler:result()).
-doc "An ordered list of `t:middleware/0` entries.".
-type middleware_list() :: [middleware()].
-doc """
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
`t:roadrunner_handler:result/0` shape every middleware returns.
The middleware decides whether to:
- pass through unchanged (`Next(Req)`),
- transform the request (`Next(Req#{...})`),
- short-circuit (return `{Response, Req}` without calling `Next`),
- wrap the response (let `Next(Req)` run, then transform what it
returned),
- run side effects around the call (log, time, instrument).
""".
-callback call(Request :: roadrunner_req:request(), Next :: next()) ->
roadrunner_handler:result().
-doc """
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.
""".
-spec compose(middleware_list(), next()) -> next().
compose([], Handler) ->
Handler;
compose([Mw | Rest], Handler) ->
Inner = compose(Rest, Handler),
fun(Req) -> apply_one(Mw, Req, Inner) end.
%% Build the handler pipeline from a combined middleware list
%% (listener-level ++ per-route) and a target handler module. The
%% resulting `next()` fun captures only `Mw` and
%% `fun Handler:handle/1`, both compile-time constants — no request
%% state — so it's safe to compose once and reuse across every
%% request that matches the route.
%%
%% Called once per route at listener init / `reload_routes/2` time,
%% from the router compile path (router-form routes) and the
%% listener's dispatch builder (single-handler dispatch tag). The
%% resulting `next()` fun lands directly in the compiled route entry
%% (and the `{handler, Mod, Pipeline, State}` dispatch tag) — no
%% wrapper map. The conn loops just call it with the request.
%%
%% Empty list → returns `fun Handler:handle/1` directly, skipping
%% `compose/2` to save one closure allocation + one indirection on the
%% no-mws fast path most production handlers hit.
-doc false.
-spec build_pipeline(middleware_list(), module()) -> next().
build_pipeline([], Handler) ->
fun Handler:handle/1;
build_pipeline(Mws, Handler) ->
compose(Mws, fun Handler:handle/1).
%% Compile a per-request pipeline `next()` fun: composes the mws
%% ending in `fun Handler:handle/1`, optionally wrapped in an
%% outermost closure that injects `state` onto the request before
%% middlewares run. Used by `roadrunner_router:compile/2` and
%% `roadrunner_listener:build_dispatch/2` to pre-bake the full
%% pipeline at compile / `reload_routes/2` time so the conn loops
%% don't allocate closures per request.
-doc false.
-spec compile_pipeline(middleware_list(), module(), no_state | {state, term()}) -> next().
compile_pipeline(Mws, Handler, no_state) ->
build_pipeline(Mws, Handler);
compile_pipeline(Mws, Handler, {state, State}) ->
Inner = build_pipeline(Mws, Handler),
fun(Req) -> Inner(Req#{state => State}) end.
-spec apply_one(middleware(), roadrunner_req:request(), next()) ->
roadrunner_handler:result().
apply_one(Mod, Req, Next) when is_atom(Mod) ->
Mod:call(Req, Next);
apply_one(Fun, Req, Next) when is_function(Fun, 2) ->
Fun(Req, Next).