Skip to main content

src/roadrunner_middleware.erl

-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).