roadrunner_middleware behaviour (roadrunner v0.7.0)

View Source

Continuation-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 unchangedNext(Req)
  • transform the requestNext(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 a Callable, optionally paired with its config as {Callable, Config}. A bare Callable is shorthand for {Callable, #{}} (empty config), the same way a {Path, Handler} route omits the state a {Path, Handler, State} route carries.

  • module() / {module(), Config} — a module implementing this behaviour. If it exports the optional init(Config) callback, that runs once at pipeline-compile time and the value it returns becomes the State handed to every Mod:call(Req, Next, State); otherwise the config is threaded verbatim. See "init/1" below.
  • fun((Request, Next, State) -> Result) / {Fun, State} — a fun has no init step, so its paired State is threaded verbatim as the third argument: Fun(Req, Next, State). Reach for a fun for lightweight inline middleware, a module when you want compile-time setup.

Config (module) and State (fun) default to #{} for the bare forms. The same callable can appear more than once with different config, 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.

init/1: optional compile-time setup (modules only)

A module middleware may implement init/1. When present, it runs once when the pipeline is compiled (listener boot and every reload_routes/2), never per request, and turns the user's raw config into the runtime state call/3 receives:

init(Config) -> State.            %% once, at compile time
call(Req, Next, State) -> Result. %% per request, the same State reused

Do config validation and any precompute (compile patterns, pre-join binaries, build lookup tables) in init/1: a bad config then fails loudly at listener start rather than on a request, and the work is paid once instead of per request. A bare module() entry is initialised with #{}, so an init/1 that reads an all-defaults config should accept the empty map.

When a module omits init/1, its config is threaded straight through as the call/3 state (#{} for a bare entry), exactly like a fun. So init/1 is purely opt-in: implement it for the compile-time hook, skip it for a stateless middleware that only needs call/3. A fun-form middleware never has an init/1 (there's no module to host the callback).

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 middleware entry's raw per-instance config: the second element of a {Callable, Config} entry, and the #{} default for a bare Callable. For a module it is the argument handed to init/1; a fun has no init, so its config is threaded straight through as the call/3 state. Typically a map.

A single entry in a middlewares list: a Callable, or a {Callable, Config} pair. Callable is either a module implementing -behaviour(roadrunner_middleware) (its init/1 runs at compile time, its call/3 per request) or a middleware_fun/0 invoked directly. The pair's second element is the entry's config/0 — fed to init/1 for a module, used verbatim as the call/3 state for a fun; a bare Callable defaults it to #{}.

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 after resolution: the {Callable, State} pair produced by running resolve/1 (which calls each module's init/1 once). Opaque — callers thread it back into compile_pipeline/4 without inspecting it.

A middleware entry's runtime state, passed as the third argument of call/3. For a module entry it is whatever init/1 returned at compile time; for a fun entry it is the entry's config/0 used verbatim.

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 what this entry's init/1 returned at compile time, or the entry's config verbatim when the module has no init/1.

Optional compile-time setup. When a module exports it, it runs once as the pipeline is built (listener boot and every roadrunner_listener:reload_routes/2), never per request, and turns the entry's raw config/0 into the state/0 handed to every call/3. A bare module() entry is initialised with #{}.

Functions

Compose a middleware list around a handler call, returning a single next() fun that runs the full pipeline.

Resolve a middleware list to its runtime resolved/0 pairs, running each module's init/1 once. Use this to resolve listener-wide middlewares a single time and reuse the result across every route (via compile_pipeline/4) instead of re-running their init per route.

Types

config()

-type config() :: term().

A middleware entry's raw per-instance config: the second element of a {Callable, Config} entry, and the #{} default for a bare Callable. For a module it is the argument handed to init/1; a fun has no init, so its config is threaded straight through as the call/3 state. Typically a map.

middleware()

-type middleware() :: module() | middleware_fun() | {module(), config()} | {middleware_fun(), config()}.

A single entry in a middlewares list: a Callable, or a {Callable, Config} pair. Callable is either a module implementing -behaviour(roadrunner_middleware) (its init/1 runs at compile time, its call/3 per request) or a middleware_fun/0 invoked directly. The pair's second element is the entry's config/0 — fed to init/1 for a module, used verbatim as the call/3 state for a fun; a bare Callable defaults it to #{}.

middleware_fun()

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

middleware_list()

-type middleware_list() :: [middleware()].

An ordered list of middleware/0 entries.

next()

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

resolved()

-opaque resolved()

A middleware entry after resolution: the {Callable, State} pair produced by running resolve/1 (which calls each module's init/1 once). Opaque — callers thread it back into compile_pipeline/4 without inspecting it.

state()

-type state() :: term().

A middleware entry's runtime state, passed as the third argument of call/3. For a module entry it is whatever init/1 returned at compile time; for a fun entry it is the entry's config/0 used verbatim.

Callbacks

call(Request, Next, State)

-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 what this entry's init/1 returned at compile time, or the entry's config verbatim when the module has no init/1.

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

init(Config)

(optional)
-callback init(Config :: config()) -> state().

Optional compile-time setup. When a module exports it, it runs once as the pipeline is built (listener boot and every roadrunner_listener:reload_routes/2), never per request, and turns the entry's raw config/0 into the state/0 handed to every call/3. A bare module() entry is initialised with #{}.

This is the place for config validation and precompute (compile patterns, pre-join binaries, build lookup tables): a bad config fails loudly at listener start, and the work is paid once rather than per request. A module that omits init/1 has its config threaded straight through as the call/3 state, the same as a fun.

Functions

compose(Mws, Handler)

-spec compose(middleware_list(), next()) -> next().

Compose a middleware list around a handler call, returning a single next() fun that runs the full pipeline.

Each module entry's init/1 is run here, once, as the pipeline is built (via resolve/1); the resulting state/0 is captured in the entry's closure and reused on every request. 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.

resolve(Mws)

-spec resolve(middleware_list()) -> [resolved()].

Resolve a middleware list to its runtime resolved/0 pairs, running each module's init/1 once. Use this to resolve listener-wide middlewares a single time and reuse the result across every route (via compile_pipeline/4) instead of re-running their init per route.