Framework-agnostic core for per-request locale negotiation.
This module holds ALL the negotiation logic shared by the optional web adapters
erli18n_cowboy and erli18n_elli, so the adapters stay thin (they only extract
raw request values and apply the result) and a single tested implementation backs
both. It is pure: no process-dictionary writes, no logging, no I/O — the
side effects (erli18n:setlocale/1, logger metadata) live in the adapters. That
keeps this module total and property-testable without a running web server.
What it does
negotiate_locale/3 resolves the request locale from an ordered list of
candidate sources, applying a configurable precedence and falling back to a
default. The default precedence used by both adapters is query > cookie >
Accept-Language header > default, mirroring i18next-http-middleware's default
order and Django's "explicit beats persisted beats browser-preferred" spirit
(see the Django locale-discovery docs in References).
Each source is tried in order; the first one that yields a supported locale
wins. Matching is delegated to erli18n_negotiate:
- a
headervalue (a rawAccept-Languagebinary) goes througherli18n_negotiate:parse_accept_language/1(RFC 9110 §12.5.4 q-values, fail-soft) and thenerli18n_negotiate:negotiate/2; - a single-value source (
query,cookie,path) iscanonicalize/1-d (so a hyphenatedpt-BRmatches an underscoredpt_BRcatalog key) and then matched the same way, which also gives the BCP-47 base-language fallback (pt_BR→pt) for free.
The single locale value an adapter feeds for the query source is itself
extracted in-module by the total query_value/2 (see "## Query parsing"): both
adapters hand it the raw query binary from the framework's non-raising
accessor and let this module decode it, rather than delegating to the
framework's own raising query decoder.
erli18n_negotiate:negotiate/2 returns {ok, Locale} on a hit and error on a
miss, which is exactly what lets this module tell "this source matched" apart
from "fall through to the next source". On a total miss the configured Default
is returned with the source tag default.
Cookie parsing
cookie_value/2 extracts a single named cookie from a raw Cookie header
binary. Both adapters use it rather than the framework's own cookie parser:
Cowboy's cowboy_req:parse_cookies/1 raises on malformed cookies, and Elli ships
no cookie parser at all. This parser is total and fail-soft (a malformed pair is
skipped, never raised) and is bounded against abuse, matching the anti-DoS
posture of erli18n_negotiate. A DQUOTE-wrapped cookie-value (RFC 6265 §4.1.1,
e.g. locale="pt_BR") has its single surrounding double-quote pair stripped.
Query parsing
query_value/2 extracts a single named query parameter from a raw query-string
binary (the part after ?, without the leading ?). Both adapters use it rather
than the framework's own query decoder: Cowboy's cowboy_req:parse_qs/1
(via cow_qs:parse_qs/1) raises on a malformed percent-escape (?x=%ZZ, a bare
?%, a truncated ?a=%E0%), and Elli's decoded accessor raises likewise. Both
adapters instead feed the raw query binary from the framework's total
accessor (Cowboy's cowboy_req:qs/1, Elli's elli_request:query_str/1 — neither
raises) and let this module decode it. The parser is total and fail-soft and
shares the cookie parser's anti-DoS posture: it is byte-capped
(> 8 KiB raw query is treated as absent) and its &-split is bounded
(take_segments/2, dropping the unscanned tail past the pair cap). Percent
(%XX) escapes and the application/x-www-form-urlencoded +-as-space rule are
decoded in-module by a total percent_decode/1; an invalid, odd, or
truncated escape makes the matched value fail-soft to undefined (the source is
skipped) rather than raising. A value-less key (?locale, no =) and an absent
key both yield undefined.
References
- RFC 9110 §12.5.4 (Accept-Language): https://www.rfc-editor.org/rfc/rfc9110.html#section-12.5.4
- RFC 6265 §4.2 (Cookie header syntax): https://www.rfc-editor.org/rfc/rfc6265.html#section-4.2
- Django locale discovery: https://docs.djangoproject.com/en/stable/topics/i18n/translation/
erli18n_negotiate— the negotiation/canonicalization engine this delegates to.
Summary
Types
A single candidate: the source and its raw value (or undefined/<<>> when the
request did not supply it, in which case the candidate is skipped).
An ordered list of candidates, highest precedence first.
An extraction callback: given a source(), returns that source's raw candidate
value from the request, or undefined/<<>> when the request does not supply
it. The ONLY impure part of negotiate_locale_lazy/4 — it is the adapter's seam
to the framework request; everything else in this module stays pure.
negotiate_locale_lazy/4 calls it AT MOST ONCE per source and stops at the
first source that yields a supported locale, so a higher-precedence winner means
the later sources are never extracted.
Where a candidate locale was read from. query/cookie/path carry a single
already-extracted locale value; header carries a raw Accept-Language header
binary. default is never an input — it is the source tag
negotiate_locale/3 returns when every candidate misses.
A zero-arity thunk deferring an expensive lookup so it is evaluated only when
actually needed. negotiate_locale_lazy/4 forces the Available thunk at most
once (on the first non-empty source value) and the Default thunk at most once
(only on a total miss), so an explicitly-supplied available/default the adapter
already has in hand costs nothing.
Functions
Extracts the value of the named cookie from a raw Cookie header binary, or
undefined if the header is absent/empty or the cookie is not present.
Resolves the request locale from Candidates (highest precedence first) against
the Available locale set, falling back to Default.
Lazy, short-circuiting variant of negotiate_locale/3 for the per-request hot
path.
Extracts the value of the named query parameter from a raw query-string binary
(the part after ?, without the leading ?), or undefined if the raw query is
absent/empty, the parameter is not present, or the parameter has no value.
Types
A single candidate: the source and its raw value (or undefined/<<>> when the
request did not supply it, in which case the candidate is skipped).
-type candidates() :: [candidate()].
An ordered list of candidates, highest precedence first.
An extraction callback: given a source(), returns that source's raw candidate
value from the request, or undefined/<<>> when the request does not supply
it. The ONLY impure part of negotiate_locale_lazy/4 — it is the adapter's seam
to the framework request; everything else in this module stays pure.
negotiate_locale_lazy/4 calls it AT MOST ONCE per source and stops at the
first source that yields a supported locale, so a higher-precedence winner means
the later sources are never extracted.
-type source() :: query | cookie | header | path.
Where a candidate locale was read from. query/cookie/path carry a single
already-extracted locale value; header carries a raw Accept-Language header
binary. default is never an input — it is the source tag
negotiate_locale/3 returns when every candidate misses.
-type thunk(T) :: fun(() -> T).
A zero-arity thunk deferring an expensive lookup so it is evaluated only when
actually needed. negotiate_locale_lazy/4 forces the Available thunk at most
once (on the first non-empty source value) and the Default thunk at most once
(only on a total miss), so an explicitly-supplied available/default the adapter
already has in hand costs nothing.
Functions
Extracts the value of the named cookie from a raw Cookie header binary, or
undefined if the header is absent/empty or the cookie is not present.
Used by both adapters instead of the framework's own cookie parser (Cowboy's
raises on malformed input; Elli has none). Total and fail-soft: a malformed
name=value pair is skipped. Two distinct anti-DoS caps apply (the same stance
as erli18n_negotiate):
- the byte cap (> 8 KiB,
?MAX_COOKIE_BYTES) treats the whole header as empty — the result isundefinedwithout scanning any pair; - the pair cap (
?MAX_COOKIE_PAIRS= 64) bounds the;-split and DROPS the unscanned tail past the 64th pair (take_segments/2); it does NOT empty the cookie — pairs within the cap are still parsed and returned, so a named cookie appearing among the first 64 pairs is found normally.
1> erli18n_http:cookie_value(<<"sid=abc; locale=pt_BR; theme=dark">>, <<"locale">>).
<<"pt_BR">>
2> erli18n_http:cookie_value(undefined, <<"locale">>).
undefined
-spec negotiate_locale(candidates(), [erli18n_negotiate:locale()], erli18n_negotiate:locale()) -> {erli18n_negotiate:locale(), source() | default}.
Resolves the request locale from Candidates (highest precedence first) against
the Available locale set, falling back to Default.
Each candidate is tried in order. A candidate whose value is undefined or empty
is skipped. A header value is parsed with
erli18n_negotiate:parse_accept_language/1; any other source's value is
erli18n_negotiate:canonicalize/1-d. The candidate's preference is matched
against Available with erli18n_negotiate:negotiate/2; the first {ok, Locale}
wins. If every candidate misses, {Default, default} is returned.
Returns {Locale, Source} — the chosen locale plus which source produced it
(default on total miss), so callers can log/emit which signal won. Callers that
only need the locale take element(1, _).
Total and fail-soft: it never raises on arbitrary Candidates values (the
delegated erli18n_negotiate functions are themselves total), and on a total
miss it falls back to Default rather than raising — the returned locale is
always a member of Available or equal to Default.
1> erli18n_http:negotiate_locale(
.. [{query, undefined}, {cookie, <<"pt-BR">>}, {header, <<"fr;q=0.9">>}],
.. [<<"pt_BR">>, <<"fr">>], <<"en">>).
{<<"pt_BR">>, cookie}
2> erli18n_http:negotiate_locale([{header, <<"de">>}], [<<"fr">>], <<"en">>).
{<<"en">>, default}
-spec negotiate_locale_lazy([source()], extract_fun(), thunk([erli18n_negotiate:locale()]), thunk(erli18n_negotiate:locale())) -> {erli18n_negotiate:locale(), source() | default}.
Lazy, short-circuiting variant of negotiate_locale/3 for the per-request hot
path.
Walks Sources in order and, for each, calls Extract(Source) to obtain that
source's raw value ONLY when the source is reached — so once an
earlier-precedence source yields a supported locale, the later sources are never
extracted (no cookie split, no header parse for a request a query already
answered). AvailableThunk is forced at most once, on the first source that
yields a non-empty value (the available index is then built once and reused for
every remaining candidate); DefaultThunk is forced at most once, only when
every source misses. So an adapter that already holds available/default
passes fun() -> Value end and pays nothing extra.
Returns {Locale, Source} exactly like negotiate_locale/3 (default on total
miss). Pure apart from the supplied Extract callback (no setlocale, no
logger, no framework calls). Total and fail-soft: a non-binary or empty
extracted value is skipped, the delegated erli18n_negotiate functions never
raise, and the result is always a member of the available set or equal to the
default.
negotiate_locale/3 is negotiate_locale_lazy/4 with an eager candidate list:
the list form stays the supported API for callers that already have all values.
Extracts the value of the named query parameter from a raw query-string binary
(the part after ?, without the leading ?), or undefined if the raw query is
absent/empty, the parameter is not present, or the parameter has no value.
Used by both adapters instead of the framework's own query decoder (Cowboy's
cowboy_req:parse_qs/1 and Elli's decoded accessor both raise on a malformed
percent-escape). The adapters feed the raw query binary from the framework's
total accessor (cowboy_req:qs/1, elli_request:query_str/1). This parser is
total and fail-soft, sharing the cookie parser's anti-DoS posture: a byte cap
(> 8 KiB raw query yields undefined) and a bounded &-split that drops the
unscanned tail past the pair cap.
Percent (%XX) escapes and the application/x-www-form-urlencoded +-as-space
rule are decoded in-module by a total percent_decode/1. An invalid, odd, or
truncated escape (%ZZ, a lone %, a truncated %E0%) is fail-soft: the
matched value decodes to undefined (the source is skipped) rather than raising.
A value-less key (?locale, no =) and an absent key both yield undefined.
1> erli18n_http:query_value(<<"foo=1&locale=pt-BR&bar=2">>, <<"locale">>).
<<"pt-BR">>
2> erli18n_http:query_value(<<"locale=%ZZ">>, <<"locale">>).
undefined
3> erli18n_http:query_value(undefined, <<"locale">>).
undefined