Internal effectful runner shared by the optional web adapters.
This module ties the pure negotiation core (erli18n_http:negotiate_locale/3) to
the per-request side effects (erli18n:setlocale/1 plus optional logger
process metadata), parameterized by a per-source candidate-extraction callback
each adapter supplies. It is the shared body that was previously duplicated across
erli18n_cowboy:execute/2 and erli18n_elli:preprocess/2.
It makes zero framework calls — it never touches cowboy_req / elli_request.
The only external module it invokes is the extraction fun the caller passes, and
that fun is where the framework seam (and its scoped suppressions) lives, in the
adapter. So this module needs no suppressions of any kind: every call it makes
(erli18n:loaded_locales/0, erli18n:default_locale/0, erli18n:setlocale/1,
erli18n_http:negotiate_locale/3, logger:update_process_metadata/1) resolves to
an in-tree or kernel/stdlib module that xref, dialyzer, and eqwalizer all see.
Purity contrast: erli18n_http stays pure (negotiation only — no setlocale, no
logger, no I/O); erli18n_http_apply is its effectful sibling (it does
setlocale and logger) but remains framework-agnostic.
Source extraction itself is lazy and short-circuiting: run/2 drives
erli18n_http:negotiate_locale_lazy/4, which calls the per-source Extract
callback only when a source is reached and stops at the first source that yields
a supported locale, so a higher-precedence winner means the later sources are
never extracted. The available and default option defaults are likewise
resolved lazily via thunks: erli18n:loaded_locales/0 is forced only once a
source yields a non-empty value and erli18n:default_locale/0 only on a total
miss, so an explicitly-supplied available/default is truly zero-cost.
Summary
Types
A per-source extraction callback supplied by an adapter: given a source tag and
the untrusted per-request option map (raw_options/0, exactly as the operator
configured it), it returns the raw candidate value (or undefined). This fun is
the ONLY thing that touches the framework request, so the seam (and its scoped
suppressions) stays in the adapter, never here. The callback reads only the
non-value-typed keys it needs (query_param, cookie_name, cowboy's
path_binding) with its own per-read guards, so it tolerates a malformed value
fail-soft. run/2 adapts it to the arity-1 erli18n_http:extract_fun/0 consumed
by the lazy engine by closing over the raw Opts.
Framework-agnostic per-request options for run/2, owned by this core module
and shared by the adapters (erli18n_cowboy extends it with path_binding;
erli18n_elli references it directly). Every key is optional; an omitted key
falls back to the documented default, and available/default are resolved
lazily so an explicitly-supplied value is zero-cost.
The untrusted per-request option map exactly as it arrives from the operator's
configuration (the erli18n key of Cowboy's env, or an Elli middleware Args
map). Unlike options/0 — which is the well-typed shape the runner relies on
internally — every value here may be arbitrary: an operator may have written a
non-binary default or a non-list available. run/2 accepts this wider type
and validate_options/1 narrows it to options/0 at the request boundary,
dropping any malformed value (fail-soft-and-observable). Modelled as an
arbitrary map (any key, any value) — the unconstrained shape an operator map
actually has at the boundary — so the malformed-value clauses of the validators
are reachable and statically checked, and a generic map() from the adapter
(e.g. Cowboy's env value) assigns to it without a narrowing cast.
Functions
Resolves the request locale and applies it as side effects.
Types
-type extract() :: fun((erli18n_http:source(), raw_options()) -> binary() | undefined).
A per-source extraction callback supplied by an adapter: given a source tag and
the untrusted per-request option map (raw_options/0, exactly as the operator
configured it), it returns the raw candidate value (or undefined). This fun is
the ONLY thing that touches the framework request, so the seam (and its scoped
suppressions) stays in the adapter, never here. The callback reads only the
non-value-typed keys it needs (query_param, cookie_name, cowboy's
path_binding) with its own per-read guards, so it tolerates a malformed value
fail-soft. run/2 adapts it to the arity-1 erli18n_http:extract_fun/0 consumed
by the lazy engine by closing over the raw Opts.
-type options() :: #{sources => [erli18n_http:source()], query_param => binary(), cookie_name => binary(), available => [erli18n_negotiate:locale()], default => erli18n_negotiate:locale(), set_logger_metadata => boolean()}.
Framework-agnostic per-request options for run/2, owned by this core module
and shared by the adapters (erli18n_cowboy extends it with path_binding;
erli18n_elli references it directly). Every key is optional; an omitted key
falls back to the documented default, and available/default are resolved
lazily so an explicitly-supplied value is zero-cost.
sources— precedence order, highest first (default[query, cookie, header]).query_param— query-string parameter carrying a locale (adapter default<<"locale">>).cookie_name— cookie carrying a locale (adapter default<<"locale">>).available— the authoritative supported-locale set (defaulterli18n:loaded_locales/0).default— fallback when nothing matches (defaulterli18n:default_locale/0).set_logger_metadata— also setloggerprocess metadata#{locale => L}(defaulttrue).
This is the precise set of keys the core runner itself reads; every key is
optional. An adapter that needs an extra key (e.g. cowboy's path_binding)
declares its own options/0 extending this shape and reads that key in its own
extraction callback — the core never reads it, so it stays out of this type.
run/2 reads each key with a default, so a user map that omits some (or carries
extra, adapter-specific) keys is consumed safely. Beyond omitted keys,
malformed option values are consumed safely too: run/2 validates the
available and default values at the request boundary (see
validate_options/1); a non-binary default or a non-list / bad-element
available is dropped — so the documented default (erli18n:default_locale/0 /
erli18n:loaded_locales/0) applies — and the misconfiguration is reported once
via logger:warning/2. So operator misconfiguration is fail-soft-and-observable,
never request-fatal: no user-supplied option value can crash the request process.
The untrusted per-request option map exactly as it arrives from the operator's
configuration (the erli18n key of Cowboy's env, or an Elli middleware Args
map). Unlike options/0 — which is the well-typed shape the runner relies on
internally — every value here may be arbitrary: an operator may have written a
non-binary default or a non-list available. run/2 accepts this wider type
and validate_options/1 narrows it to options/0 at the request boundary,
dropping any malformed value (fail-soft-and-observable). Modelled as an
arbitrary map (any key, any value) — the unconstrained shape an operator map
actually has at the boundary — so the malformed-value clauses of the validators
are reachable and statically checked, and a generic map() from the adapter
(e.g. Cowboy's env value) assigns to it without a narrowing cast.
Functions
-spec run(extract(), raw_options()) -> erli18n_negotiate:locale().
Resolves the request locale and applies it as side effects.
Given an adapter's per-source Extract callback and its Opts, this delegates
to erli18n_http:negotiate_locale_lazy/4, which extracts each source on demand
in sources order and stops at the first source that yields a supported locale,
then sets the locale on the calling process via erli18n:setlocale/1, optionally
updates logger process metadata, and returns the chosen locale.
The sources, available, and default defaults are resolved lazily from
Opts: available (default erli18n:loaded_locales/0) is forced only when a
source actually yields a non-empty value, default (default
erli18n:default_locale/0) only on a total miss, and an explicitly-supplied
available/default is never recomputed (truly zero-cost). set_logger_metadata
defaults to true.
Opts is first passed through validate_options/1, which drops any malformed
available/default value (falling back to the documented default and emitting
one logger:warning/2), so an operator misconfiguration is fail-soft-and-observable
rather than request-fatal.