erli18n_http (erli18n v0.8.0)

Copy Markdown View Source

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 header value (a raw Accept-Language binary) goes through erli18n_negotiate:parse_accept_language/1 (RFC 9110 §12.5.4 q-values, fail-soft) and then erli18n_negotiate:negotiate/2;
  • a single-value source (query, cookie, path) is canonicalize/1-d (so a hyphenated pt-BR matches an underscored pt_BR catalog key) and then matched the same way, which also gives the BCP-47 base-language fallback (pt_BRpt) 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_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

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

candidate()

-type candidate() :: {source(), binary() | undefined}.

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).

candidates()

-type candidates() :: [candidate()].

An ordered list of candidates, highest precedence first.

extract_fun()

-type extract_fun() :: fun((source()) -> binary() | undefined).

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.

source()

-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.

thunk(T)

-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

negotiate_locale(Candidates, Available, Default)

-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}

negotiate_locale_lazy(Sources, Extract, AvailableThunk, DefaultThunk)

-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.

query_value/2

-spec query_value(binary() | undefined, binary()) -> binary() | undefined.

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