Back to Hologram

Middleware

docs-middleware.md

latest14.7 KB
Original Source

Middleware

Middleware is reusable, server-side logic that runs before a page renders or a command executes. It is a natural home for cross-cutting concerns such as authentication, authorization, request enrichment (locale, theme, tenant, feature flags), rate limiting, or audit logging.

Middleware is server-only. Actions run on the client and cross no trust boundary, so they have no middleware - anything privileged an action needs goes through a command, which middleware gates server-side.

Defining middleware

Middleware lives in one of two places: an inline function on the page or component itself, or a module you reuse across pages and components. Inline suits one-off logic specific to a single page or component. A module suits anything shared, and is written as a leaf (does the work itself) or a composite (combines other middleware). The Server helpers (put_status, put_redirect, put_session, put_stash, and the rest) are available unqualified in all of them.

Inline middleware

The simplest middleware is a function defined right on the page or component, attached by name with middleware :name. It receives the Server struct and any options it was attached with ([] when none), and returns a Server struct - the same one, or one it has updated, which threads into the next middleware.

defmodule MyApp.DashboardPage do
  use Hologram.Page

  route "/dashboard"

  middleware :put_locale

  def put_locale(server, _opts) do
    put_stash(server, :locale, get_request_header(server, "accept-language"))
  end
end

Options passed in the declaration arrive as the second argument:

middleware :put_theme, default: "dark"

def put_theme(server, opts) do
  put_stash(server, :theme, get_cookie(server, "theme") || opts[:default])
end

Leaf middleware

A leaf is the reusable form: a module that defines call/2, with the same (server, opts) signature as an inline function but in its own module so multiple pages or components can share it.

defmodule MyApp.Middleware.RequireAuth do
  use Hologram.Middleware

  @impl Hologram.Middleware
  def call(server, _opts) do
    require_auth(server, get_session(server, :user_id))
  end

  defp require_auth(server, nil), do: put_redirect(server, MyApp.LoginPage)
  defp require_auth(server, _user_id), do: server
end

Composite middleware

A composite declares a sub-chain with the middleware macro instead of defining call/2 - the framework generates the call/2 that runs that sub-chain. Because a composite has a call/2 like any other middleware, it attaches anywhere a leaf can - including inside another composite. A terminal response from any member stops the rest, and that short-circuit carries through nested composites.

defmodule MyApp.Middleware.AdminGate do
  use Hologram.Middleware

  middleware MyApp.Middleware.RequireAuth
  middleware MyApp.Middleware.RequireAdmin
end

Attaching middleware

Attach middleware to a page or component with the middleware macro. The target is either a module or an inline :function name, and each takes optional keyword options:

  • middleware SomeModule - runs SomeModule.call(server, opts).
  • middleware SomeModule, max: 100 - same, with options passed as the second argument.
  • middleware :some_function - runs the host module's own inline some_function(server, opts).
  • middleware :some_function, role: :admin - same, with options.

Attach as many as you like, mixing inline functions and modules. Declarations run top to bottom, in the order written.

Composition

Composition is explicit. Middleware composes two ways: a base module that shares its declarations with every page or component that uses it, and a composite that bundles middleware into a single unit (shown above). Both resolve at compile time into one flat chain, with no runtime registry and nothing injected behind your back, so the full chain stays visible where it is attached - good for review.

A base module whose __using__ injects middleware declarations passes them on to every page or component that uses it - the base can wrap use Hologram.Page or use Hologram.Component. The child's own declarations accumulate after the inherited ones (parent first, then child), so a shared set plus a child-specific addition is just one more middleware line.

defmodule MyApp.AdminPage do
  defmacro __using__ (_opts) do
    quote do
      use Hologram.Page

      middleware MyApp.Middleware.RequireAuth

      layout MyApp.AdminLayout
    end
  end
end
defmodule MyApp.SettingsPage do
  use MyApp.AdminPage

  route "/admin/settings"

  middleware MyApp.Middleware.RequireAdmin

  # ...
end

App-wide concerns are assembled per page or component - a shared base module or composite plugged into the ones that need it. This avoids the anti-pattern of a global gate wrongly applying to public pages (login, signup) that need no auth. Truly-universal concerns that apply to every request - CSRF protection and telemetry, for example - are handled at the framework level, not via your middleware.

Dispatch and scoping

Where middleware runs depends on where it is attached:

  • Page middleware runs before the page renders (before its init/3) and before any of that page's commands.
  • Component middleware runs only before that component's own commands. A component is always rendered as part of a page, never on its own, so there is no separate component render for middleware to gate.

Component middleware lets a distributable component gate its own commands regardless of where it is embedded.

Security - a page's middleware does not cover its components' commands. Dispatch is flat. It runs only the target module's middleware, never a tree of them. So when a command targets a component - even one nested deep inside a page you have gated - that component's middleware runs, but the page's and any ancestor component's do not. There is no inheritance up the nesting. To protect a component's commands, attach the gate to the component itself, directly or through a shared composite the page and component both use. Do not rely on the page's gate to cover them.

Ordering note: anything that must run even on a rejected request (audit logging especially) goes early in the chain, since a terminal response stops everything after it.

The Server struct

Middleware operates on a Server struct - the same struct init/3 and command/3 receive. It has three zones:

  • Request (read-only) - the incoming request, read as struct fields: method, scheme, host, port, path, query, raw_query, and ip.
  • Response - the response being built: its status, headers, and body.
  • Stash - a request-scoped scratchpad for passing data to later middleware in the chain and on to init/3 or command/3.

Identity, session, and cookies live here too. The functions for reading the request and writing each zone are in the Helper reference below. Enrichment is a common middleware job - the example here resolves the current user once and stashes it, so everything downstream can read it:

defmodule MyApp.Middleware.LoadCurrentUser do
  use Hologram.Middleware

  @impl Hologram.Middleware
  def call(server, _opts) do
    load_user(server, get_session(server, :user_id))
  end

  defp load_user(server, nil), do: server

  defp load_user(server, user_id) do
    put_stash(server, :current_user, Accounts.get_user(user_id))
  end
end

Terminal responses

Middleware stops the request by producing a response - that is, by setting a status on the server. put_status sets that field rather than halting on the spot - the middleware runs to completion, and once it returns with a status set, the rest of the chain and the handler are skipped, and the response is built from the status, headers, and body the chain accumulated. Decorations such as cookies and headers set before the stop still apply, so denying and setting a cookie both land.

There is no separate halt - the status is the signal, set as an integer status code or an atom alias. A redirect sets a status, so it is inherently terminal:

  • put_redirect(server, MyApp.LoginPage) - stops and redirects (302 by default).
  • put_status(server, 403) - stops with a Forbidden response.
  • put_status(server, :not_found) - stops and renders the app's registered 404 page.
defmodule MyApp.Middleware.RequireAdmin do
  use Hologram.Middleware

  @impl Hologram.Middleware
  def call(server, _opts) do
    check_role(server, get_session(server, :role))
  end

  defp check_role(server, :admin), do: server
  defp check_role(server, _role), do: put_status(server, :forbidden)
end

Proxy and trust model

The request fields reflect what the client claimed or what an upstream proxy resolved - they are not authenticated.

  • host is client-supplied and spoofable , even with no proxy in front. Validate it against an allowlist before using it for a security decision (for example, tenant routing). Never build security-sensitive absolute URLs - password-reset links, emails - from host or request_url/1. Use a configured canonical URL instead.
  • ip / scheme resolution is an ingress concern. In embedded mode (Hologram inside your Phoenix endpoint), add RemoteIp and/or Plug.RewriteOn to your endpoint to resolve the real client ip / scheme behind a proxy. Hologram reads the already-resolved conn and does no resolution of its own, so if you do not configure these, ip is the proxy's address. In standalone mode , Hologram resolves ip / scheme / port out of the box (trusted-proxy-gated when behind a proxy), while still requiring you to validate host.

Key conventions

The three keyed stores differ in key type, by lifetime:

  • Stash - atom keys. In-memory, request-scoped, never serialized.
  • Session - string keys. Atom keys are accepted and converted to strings (in embedded mode the session is backed by the Phoenix session store, which keys by string - recovering atoms would need an unsafe string-to-atom conversion). See Session.
  • Cookies - string keys only. See Cookies.

Helper reference

These helpers are imported into pages, components, and use Hologram.Middleware modules, so you call them unqualified. The get_* readers take an optional final default, returned when the value is absent (otherwise nil).

Reading the request

  • get_request_header(server, name) - reads a request header by case-insensitive name.
  • referrer_url(server) - the referring URL, or nil.
  • request_url(server) - the full request URL assembled from the request fields. Client-claimed, so never use it for security-sensitive links (see Proxy and trust model).

Building the response

  • append_response_header(server, name, value) - appends a value to the response header name, keeping that header's existing value (comma-separated) instead of replacing it like put_response_header.
  • delete_response_header(server, name) - removes a response header.
  • get_response_header(server, name) - reads a header already set on the response.
  • put_redirect(server, url_or_page) - a 302 redirect to either a URL string or a page module.
  • put_redirect(server, page, params) - a 302 redirect to a page module with route params.
  • put_response_body(server, body) - sets the response body (iodata). Not terminal on its own - pair it with put_status.
  • put_response_header(server, name, value) - sets a response header (name lower-cased), replacing any existing value. Raises on cookie / set-cookie - use the cookie helpers for those.
  • put_status(server, status) - sets the response status and stops the chain. status is an integer like 403 or an atom alias like :forbidden or :not_found. An unknown alias or out-of-range integer raises. The full alias set is in Status aliases below.

Identity

  • delete_user_id(server) - clears the authenticated user.
  • put_user_id(server, user_id) - records the authenticated user (a string, integer, or atom), persisted to the session.

Session and cookies

Available here too, documented in full on their own pages:

  • Session - get_session / put_session / delete_session. See Session.
  • Cookies - get_cookie / put_cookie / delete_cookie. See Cookies.

Stash

  • delete_stash(server, key) - removes a stashed value.
  • get_stash(server, key) - reads a stashed value.
  • put_stash(server, key, value) - stashes a value for downstream middleware and handlers.

Status aliases

Each status code and the atom alias put_status accepts for it. An unknown alias raises.

CodeAlias
100:continue
101:switching_protocols
102:processing
103:early_hints
200:ok
201:created
202:accepted
203:non_authoritative_information
204:no_content
205:reset_content
206:partial_content
207:multi_status
208:already_reported
226:im_used
300:multiple_choices
301:moved_permanently
302:found
303:see_other
304:not_modified
305:use_proxy
307:temporary_redirect
308:permanent_redirect
400:bad_request
401:unauthorized
402:payment_required
403:forbidden
404:not_found
405:method_not_allowed
406:not_acceptable
407:proxy_authentication_required
408:request_timeout
409:conflict
410:gone
411:length_required
412:precondition_failed
413:payload_too_large
414:uri_too_long
415:unsupported_media_type
416:range_not_satisfiable
417:expectation_failed
418:im_a_teapot
421:misdirected_request
422:unprocessable_entity
423:locked
424:failed_dependency
425:too_early
426:upgrade_required
428:precondition_required
429:too_many_requests
431:request_header_fields_too_large
451:unavailable_for_legal_reasons
500:internal_server_error
501:not_implemented
502:bad_gateway
503:service_unavailable
504:gateway_timeout
505:http_version_not_supported
506:variant_also_negotiates
507:insufficient_storage
508:loop_detected
510:not_extended
511:network_authentication_required

Sponsored by

Main sponsor

Milestone sponsor

Previous

← Context

Next

Session →