arizona_middleware (arizona v0.4.0)

View Source

The request-to-bindings middleware pipeline.

A middleware reduces an incoming request into the plain bindings a mount/1 handler consumes, so handlers (and the live process) stay free of any request or transport coupling. Each step receives the request and the bindings gathered so far and returns either {cont, Request, Bindings} to continue or {halt, Request} to stop.

Running

apply_middlewares/3 threads a request and bindings map through a list of steps left-to-right, stopping on the first halt. Transports (arizona_http, arizona_ws, arizona_socket) run it before mounting a view; a route lists its steps under the middlewares key of arizona_live:route_opts/0.

A halting step says what the client sees by stashing a redirect (arizona_req:redirect/2,3) or a status (arizona_req:put_resp_status/2, as check_origin/2 does with its 403). A bare {halt, Request} carrying neither is answered 403 Forbidden -- the same on a page render, a controller, and a WebSocket upgrade.

Built-in steps

  • extract/1 -- copy selected request data (path bindings, params, headers, ...) into bindings so a handler reads it with ?get(Key).
  • put_request/2 -- the escape hatch: expose the whole request under the request binding for lazy access.
  • fetch_flash/2 -- read the incoming flash (set on the prior request via arizona_req:put_flash/3) into the flash binding, consuming it.
  • fetch_session/2 -- read the durable session into the session binding, without consuming it.
  • check_origin/2 -- CSRF defense: halt with 403 when the request Origin is not trusted (see arizona_origin). The router applies it to live/controller routes by default, so you rarely list it by hand.

HTTP response transforms

The list accepts a second kind of entry: an HTTP response transform, built by etag/0, compress/0, cors/1, or security_headers/0,1. A transform is not a request-to-bindings step: it wraps the whole HTTP exchange (the server applies it in its own response pipeline), so it can see and rewrite the response -- the thing a step, which runs before any render and also on paths with no HTTP response at all, cannot. Order has phase semantics: steps run in list order during the request phase; transforms wrap the exchange in list order, first outermost -- except compression, which the router keeps OUTERMOST wherever it is written (or attaches itself), so a transform always sees the response before it is re-encoded. On a pipeline run with no HTTP response to wrap (the WebSocket upgrade, a navigate/patch), a transform is inert and skipped.

Each constructor returns a plain, stable term, and that term -- not the call -- is what a config/sys.config route table must spell (a term file cannot hold a function call; see "Declaring steps" below for why):

constructorliteral term
etag(){http_transform, etag}
compress(){http_transform, compress}
cors(Config){http_transform, {cors, Config}}
security_headers(){http_transform, security_headers}
security_headers(Config){http_transform, {security_headers, Config}}

The named set is deliberately the whole vocabulary: a transform names an HTTP semantic, and the server adapter maps it to that server's own implementation, so a route never says which server runs beneath it. A custom server-level middleware has no per-route spelling here -- it goes in the server's own listener config (proto_opts), the one place that is server-specific by definition.

Declaring steps

A step is either a fun of two arguments or a {Module, Function} pair naming a 2-arity function. Which of the two you can write depends on where the route is declared.

A route declared in Erlang takes both forms, so it can call a builder like extract/1 inline:

#{middlewares => [arizona_middleware:extract([path_bindings, params])]}

A route declared in config/sys.config takes only the pair. An application config file is read by file:consult/1, which parses terms, and arizona_middleware:extract([path_bindings]) is a function call rather than a term -- so the file fails to parse and the node refuses to boot:

===> Error reading file .../config/sys.config: 1: bad term

The reported line is always 1, because the whole file is a single term, so it points nowhere near the offending expression. Note that fun Module:Function/Arity with literal parts is a term (which is why the reloader's callback => fun arizona_reloader:reload_erl/1 consults fine); it is specifically the call that is not.

So a parameterised step -- extract/1, or a builder of your own -- needs a named 2-arity function for a config file to spell:

%% config/sys.config -- an entry in the server's `routes` list
{live, ~"/users/:id", user_page, #{middlewares => [{my_app_mw, path_bindings}]}}
%% src/my_app_mw.erl
path_bindings(Req, Bindings) ->
    Extract = arizona_middleware:extract([path_bindings]),
    Extract(Req, Bindings).

{arizona_middleware, extract} is not that wrapper. It type-checks, then fails at request time with undef: the pair names extract/2, and extract/1 is the builder.

Summary

Functions

Runs Middlewares left-to-right, threading Request and Bindings through each step. Stops on the first {halt, Request} and returns it.

CSRF defense: halt with 403 when the request Origin is not trusted (same-origin or allowlisted -- see arizona_origin), continue otherwise. A missing Origin (native clients, CLI tools, top-level GET navigations) continues.

HTTP response transform: gzip/deflate response compression. Only needed on routes the framework does not already compress -- controller routes; live and asset routes get compression from the server's compress option (default on), where listing it again changes nothing. Position-independent by construction: the router keeps compression OUTERMOST wherever it is written, so [etag(), compress()] and [compress(), etag()] compile to the same pipeline and an etag always hashes the pre-compression body. An explicit compress() also survives the server's compress => false (a route-level request beats the listener-level default-off). Returns the plain term {http_transform, compress} -- the spelling a sys.config route uses.

HTTP response transform: CORS -- answers the browser's preflight OPTIONS and decorates cross-origin responses with Access-Control-* headers per Config. The config keys are Arizona's contract for this transform -- origins at minimum, plus the usual CORS knobs (methods, allowed headers, credentials) -- which the server adapter maps to its own CORS implementation. Distinct from check_origin/2, which REJECTS cross-origin requests -- list this on a route that deliberately serves them (usually beside check_origin => false). Returns the plain term {http_transform, {cors, Config}} -- the spelling a sys.config route uses.

HTTP response transform: derive a weak ETag from the response body and answer a matching If-None-Match with 304 (RFC 7232). The validator is weak on purpose, so the framework's compression re-encoding the body outside it cannot invalidate the tag. Returns the plain term {http_transform, etag} -- the spelling a sys.config route uses.

Builds a middleware that copies selected request data into bindings, so a mount/1 handler can read it with ?get(Key) without touching the request.

Middleware that reads the incoming flash into the flash binding (read with ?get(flash), a map; #{} when there is none).

Middleware that reads the durable session into the session binding (read with ?get(session), a map; #{} when there is none).

Middleware that exposes the whole request to a handler under the request binding (read with ?get(request)).

HTTP response transform: a default-safe security-header set (x-content-type-options, x-frame-options, referrer-policy, ...) added to every response, leaving headers the handler already set untouched. security_headers/1 overrides individual headers. Returns the plain term {http_transform, security_headers} (with config: {http_transform, {security_headers, Config}}) -- the spelling a sys.config route uses.

Types

extract_key()

-nominal extract_key() :: path_bindings | params | headers | cookies | body | method | user_agent.

http_transform()

-type http_transform() ::
          {http_transform,
           etag | compress | security_headers | {cors, map()} | {security_headers, map()}}.

middleware()

-nominal middleware() ::
             fun((arizona_req:request(), az:bindings()) -> middleware_result()) |
             {module(), atom()} |
             http_transform().

middleware_result()

-nominal middleware_result() ::
             {cont, arizona_req:request(), az:bindings()} | {halt, arizona_req:request()}.

Functions

apply_middlewares(Middlewares, Request, Bindings)

-spec apply_middlewares(Middlewares, Request, Bindings) -> middleware_result()
                           when
                               Middlewares :: [middleware()],
                               Request :: arizona_req:request(),
                               Bindings :: az:bindings().

Runs Middlewares left-to-right, threading Request and Bindings through each step. Stops on the first {halt, Request} and returns it.

check_origin(Req0, Bindings)

-spec check_origin(arizona_req:request(), az:bindings()) -> middleware_result().

CSRF defense: halt with 403 when the request Origin is not trusted (same-origin or allowlisted -- see arizona_origin), continue otherwise. A missing Origin (native clients, CLI tools, top-level GET navigations) continues.

The router prepends this to live and controller routes by default; opt a route out with check_origin => false, or disable globally with the check_origin app env.

The Origin is compared against the request's scheme as well as its Host, so an HTTPS request refuses a plain-http Origin. The scheme is the connection's own (arizona_req:scheme/1), upgraded to https when a proxy in front sets X-Forwarded-Proto: https -- an upgrade-only read, so the header cannot be forged into weakening the check.

#{middlewares => [{arizona_middleware, check_origin}]}

compress()

-spec compress() -> http_transform().

HTTP response transform: gzip/deflate response compression. Only needed on routes the framework does not already compress -- controller routes; live and asset routes get compression from the server's compress option (default on), where listing it again changes nothing. Position-independent by construction: the router keeps compression OUTERMOST wherever it is written, so [etag(), compress()] and [compress(), etag()] compile to the same pipeline and an etag always hashes the pre-compression body. An explicit compress() also survives the server's compress => false (a route-level request beats the listener-level default-off). Returns the plain term {http_transform, compress} -- the spelling a sys.config route uses.

cors(Config)

-spec cors(Config) -> http_transform() when Config :: map().

HTTP response transform: CORS -- answers the browser's preflight OPTIONS and decorates cross-origin responses with Access-Control-* headers per Config. The config keys are Arizona's contract for this transform -- origins at minimum, plus the usual CORS knobs (methods, allowed headers, credentials) -- which the server adapter maps to its own CORS implementation. Distinct from check_origin/2, which REJECTS cross-origin requests -- list this on a route that deliberately serves them (usually beside check_origin => false). Returns the plain term {http_transform, {cors, Config}} -- the spelling a sys.config route uses.

The route's method allowlist is enforced before any transform runs, so the router adds OPTIONS to a cors route that does not declare it: a preflight then reaches this transform and gets its 204, and a plain OPTIONS (no Access-Control-Request-Method) is answered 204 with the Allow set a 405 on that URL would carry, never reaching the controller action or live page. A route that declares OPTIONS itself, or allows any method, keeps the plain OPTIONS for its own handler. A route declaring OPTIONS on a URL this one also matches must be listed before it; listed after, it would never be reached, and route compilation fails naming both.

The default Origin check still runs on the request that follows a preflight, so an origin listed here must also be in csrf_origins (or the route sets check_origin => false); otherwise the browser gets a preflight 204 and then a 403.

etag()

-spec etag() -> http_transform().

HTTP response transform: derive a weak ETag from the response body and answer a matching If-None-Match with 304 (RFC 7232). The validator is weak on purpose, so the framework's compression re-encoding the body outside it cannot invalidate the tag. Returns the plain term {http_transform, etag} -- the spelling a sys.config route uses.

extract(Keys)

-spec extract(Keys) -> middleware() when Keys :: [extract_key()].

Builds a middleware that copies selected request data into bindings, so a mount/1 handler can read it with ?get(Key) without touching the request.

Each key targets a binding:

  • path_bindings -- route path bindings merged into the bindings map (so /users/:id is read as ?get(<<"id">>))
  • params / headers / cookies / body / method / user_agent -- stored under the same-named key

body is the one key that can fail: when the transport cannot deliver the body (only possible when the server reads it on demand, e.g. roadrunner's body_buffering => manual), the step halts with 413 (over the body cap), 431 (a chunked body's trailers over the request-header limits), 408 (the client stalled or sent too slowly) or 400 (malformed, or the connection dropped). These match what the server answers when it buffers the body itself, except that a server may drop a too-slow client without any response where this step still answers it 408.

#{middlewares => [arizona_middleware:extract([path_bindings, params])]}

That call builds the middleware where the route is declared, so it can only be written in Erlang. A route declared in config/sys.config must name a 2-arity wrapper around it instead -- see "Declaring steps" in the module doc.

fetch_flash(Req, Bindings)

-spec fetch_flash(arizona_req:request(), az:bindings()) -> middleware_result().

Middleware that reads the incoming flash into the flash binding (read with ?get(flash), a map; #{} when there is none).

Opt in on routes that display flash messages. Reading consumes the flash: the response clears its cookie, so a refresh shows nothing. Pair it with arizona_req:put_flash/3 before a redirect (the Post/Redirect/Get pattern).

#{middlewares => [{arizona_middleware, fetch_flash}]}

fetch_session(Req, Bindings)

-spec fetch_session(arizona_req:request(), az:bindings()) -> middleware_result().

Middleware that reads the durable session into the session binding (read with ?get(session), a map; #{} when there is none).

Opt in on routes that need the session. Unlike fetch_flash/2, reading does not consume it: the session survives untouched, and the response re-emits the cookie only when the app writes it (arizona_req:put_session/3) or clears it (clear_session/1). It runs on both the GET render and the WS upgrade, so a live view is seeded with the session at mount.

A server-side store outage is left on the request (arizona_req:session_error/1), not the session binding (which stays #{}, an ordinary signed-out read). A route that wants the outage visible in a view can add its own middleware after this one that lifts arizona_req:session_error(Req) into a binding.

#{middlewares => [{arizona_middleware, fetch_session}]}

put_request(Req, Bindings)

-spec put_request(arizona_req:request(), az:bindings()) -> middleware_result().

Middleware that exposes the whole request to a handler under the request binding (read with ?get(request)).

The escape hatch for a handler that needs lazy access to many request fields; it couples that handler to HTTP. Prefer extract/1 for specific data.

The request binding is mount-scoped: it is read during mount/1, and because mount returns a fresh bindings map (construct, don't merge) it does not persist into the live process or carry across an SPA navigate.

#{middlewares => [{arizona_middleware, put_request}]}

security_headers()

-spec security_headers() -> http_transform().

HTTP response transform: a default-safe security-header set (x-content-type-options, x-frame-options, referrer-policy, ...) added to every response, leaving headers the handler already set untouched. security_headers/1 overrides individual headers. Returns the plain term {http_transform, security_headers} (with config: {http_transform, {security_headers, Config}}) -- the spelling a sys.config route uses.

security_headers(Config)

-spec security_headers(Config) -> http_transform() when Config :: map().