Optional Cowboy middleware that negotiates the request locale and sets it before the handler runs — turnkey per-request localization.
cowboy is an optional dependency (declared via optional_applications, like
telemetry): erli18n does not require it and the published package still builds
and runs on kernel + stdlib alone. This module only executes when a Cowboy
application installs it in its middleware chain, so Cowboy is by definition
present whenever the code runs. cowboy_req is therefore an externally-provided
runtime module rather than a build dependency — the same situation as the rebar3
host API in rebar3_erli18n_host — so its calls are confined to a small seam
(see the "cowboy_req seam" section below) and the default build's xref, dialyzer,
and eqwalizer are kept clean by suppressions scoped to exactly those external
edges: -ignore_xref + the root rebar.config {xref_ignores,...}, a
function-scoped -dialyzer({no_unknown,...}), a % elp:ignore W0017 per call
site, and — for the two seam calls whose upstream cowboy_req specs return an
any()-tainted union eqwalizer cannot refine (header/3, binding/2) — a
function-scoped -eqwalizer({nowarn_function,...}). Every other call stays fully
checked.
Install it
Add cowboy to your application's deps (it is not pulled in by erli18n), then
put erli18n_cowboy in the middleware list ahead of cowboy_handler (and after
cowboy_router if you negotiate from a path binding). Pass options under the
erli18n key of the protocol env:
%% Load your catalogs once at boot.
application:ensure_all_started(erli18n),
{ok, _} = erli18n_server:ensure_loaded(my_domain, <<"pt_BR">>, "priv/.../my_domain.po"),
Dispatch = cowboy_router:compile([{'_', [{"/[...]", my_handler, []}]}]),
{ok, _} = cowboy:start_clear(http, [{port, 8080}], #{
env => #{
dispatch => Dispatch,
%% Middleware options (all optional — see "Options" below):
erli18n => #{cookie_name => <<"locale">>, query_param => <<"lang">>}
},
middlewares => [erli18n_cowboy, cowboy_router, cowboy_handler]
}).When you negotiate from a path binding (path_binding), erli18n_cowboy
must run AFTER cowboy_router (the router is what fills in the bindings the
adapter reads) but still before cowboy_handler:
Dispatch = cowboy_router:compile([
{'_', [{"/:locale/[...]", my_handler, []}]}
]),
{ok, _} = cowboy:start_clear(http, [{port, 8080}], #{
env => #{
dispatch => Dispatch,
erli18n => #{sources => [path, query, cookie, header], path_binding => locale}
},
%% erli18n_cowboy AFTER cowboy_router so the `:locale` binding is populated:
middlewares => [cowboy_router, erli18n_cowboy, cowboy_handler]
}).In the handler the locale is already set for the request process, so the gettext families need no locale argument:
init(Req, State) ->
Title = erli18n:gettext(my_domain, <<"Welcome">>), %% uses the negotiated locale
{ok, cowboy_req:reply(200, #{}, Title, Req), State}.Options (the erli18n key of env, all optional)
| Key | Default | Meaning |
|---|---|---|
sources | [query, cookie, header] | precedence order, highest first |
query_param | <<"locale">> | query-string parameter carrying a locale |
cookie_name | <<"locale">> | cookie carrying a locale |
path_binding | undefined | a cowboy_router binding to read (enables the path source) |
available | erli18n:loaded_locales/0 | the authoritative supported-locale set |
default | erli18n:default_locale/0 | fallback when nothing matches |
set_logger_metadata | true | also set logger process metadata #{locale => L} |
The default precedence is query > cookie > Accept-Language header > default
(i18next-http-middleware's default order; Django's "explicit beats persisted
beats browser-preferred" spirit). available/default are resolved per
request (never captured at install time), so they reflect the catalogs loaded
at the moment of the request. Negotiation, canonicalization, and cookie parsing
are delegated to erli18n_http (and through it erli18n_negotiate); see those
modules for the matching and BCP-47 fallback semantics.
The chosen locale is also written into the Cowboy Env under erli18n_locale,
so downstream middlewares or the handler can read it explicitly rather than
relying on the process dictionary.
The locale is per-process — and per-process state does not cross a spawn
erli18n:setlocale/1 writes the locale to the calling process's dictionary,
and Cowboy runs the whole middleware chain plus the handler in one request
process, so the locale this middleware sets is visible to the handler with no
extra wiring. But process state is not inherited across a spawn. Any time a
request handler hands work to a different process, that process starts with
erli18n:which_locale() = undefined and falls back to default_locale/0. This
silently affects:
- a worker pool (
poolboy, agen_server, agen_statem) youcall/cast; - a
Task-style /proc_lib:spawn/erlang:spawnbackground job; - a Cowboy stream handler that offloads work to another process.
Mitigations — pick per call site:
- Capture and re-set in the worker. In the request process read
Locale = erli18n:which_locale(), hand it across, and callerli18n:setlocale(Locale)as the first statement on the other side. - Pass it explicitly. Thread the locale as a function/message argument and
use the
*locale*-suffixed lookups, e.g.erli18n:gettext(Domain, Msgid, Locale), instead of relying on ambient state. - Propagate via logger metadata. With
set_logger_metadataon, the locale is in this process'sloggermetadata; carry that same map into the worker (logger metadata is itself per-process and not inherited).
Phoenix / mixed Elixir stacks (no Elixir dependency)
erli18n takes no Elixir or Phoenix dependency. In a mixed stack the bridge is the
shared per-process locale: call erli18n:setlocale/1 from a Plug (an Erlang
module is callable from Elixir as :erli18n.setlocale(locale)) in the same
request process that runs your erli18n lookups — typically right where you would
call Gettext.put_locale/1. If you also use Elixir Gettext, set both from that
one plug so the two libraries agree; erli18n's canonical locale form ("pt_BR",
underscore) already matches Gettext's. Nothing here links the two libraries at
build time.
References
- Cowboy middlewares: https://ninenines.eu/docs/en/cowboy/2.13/guide/middlewares/
cowboy_req: https://ninenines.eu/docs/en/cowboy/2.13/manual/cowboy_req/loggerprocess metadata: https://www.erlang.org/doc/apps/kernel/logger.html
Summary
Types
Middleware options, read from the erli18n key of the Cowboy protocol env.
Stands in for cowboy_req:req/0 (a map). A module-local alias so the default
kernel + stdlib build needs no cowboy dependency.
Functions
The cowboy_middleware callback. Negotiates the locale from the request, sets it
on the request process (and, by default, in logger metadata), records it in
Env under erli18n_locale, and continues the chain with {ok, Req, Env}.
Types
-type options() :: #{sources => [erli18n_http:source()], query_param => binary(), cookie_name => binary(), path_binding => atom(), available => [erli18n_negotiate:locale()], default => erli18n_negotiate:locale(), set_logger_metadata => boolean()}.
Middleware options, read from the erli18n key of the Cowboy protocol env.
The path_binding-extended form of erli18n_http_apply:options/0 (the shared
framework-agnostic option set): every shared key plus the Cowboy-only
path_binding that enables the path source. Every key is optional; see the
module docs for defaults and semantics.
-type req() :: map().
Stands in for cowboy_req:req/0 (a map). A module-local alias so the default
kernel + stdlib build needs no cowboy dependency.
Functions
The cowboy_middleware callback. Negotiates the locale from the request, sets it
on the request process (and, by default, in logger metadata), records it in
Env under erli18n_locale, and continues the chain with {ok, Req, Env}.
Req is the Cowboy request map and Env the middleware environment map; this
function never replies or stops the chain, so it composes with any handler. The
-behaviour(cowboy_middleware) attribute is intentionally omitted so the module
compiles warning-free (under warnings_as_errors) when Cowboy is absent from the
build; the callback contract is exercised by the test suite against the real
framework instead.