arizona_middleware (arizona v0.4.0)
View SourceThe 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 therequestbinding for lazy access.fetch_flash/2-- read the incoming flash (set on the prior request viaarizona_req:put_flash/3) into theflashbinding, consuming it.fetch_session/2-- read the durable session into thesessionbinding, without consuming it.check_origin/2-- CSRF defense: halt with403when the requestOriginis not trusted (seearizona_origin). The router applies it tolive/controllerroutes 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):
| constructor | literal 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 termThe 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
-nominal extract_key() :: path_bindings | params | headers | cookies | body | method | user_agent.
-nominal middleware() :: fun((arizona_req:request(), az:bindings()) -> middleware_result()) | {module(), atom()} | http_transform().
-nominal middleware_result() :: {cont, arizona_req:request(), az:bindings()} | {halt, arizona_req:request()}.
Functions
-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.
-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}]}
-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.
-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.
-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.
-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/:idis 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.
-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}]}
-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}]}
-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}]}
-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.
-spec security_headers(Config) -> http_transform() when Config :: map().