AcceptLanguage

CIHex VersionHex DocsElixirLicense

A lightweight, zero-dependency Elixir library for parsing the Accept-Language HTTP header field.

This implementation conforms to:

Note RFC 7231 obsoletes RFC 2616 (the original HTTP/1.1 specification). The Accept-Language header behavior defined in RFC 2616 Section 14.4 remains unchanged in RFC 7231, ensuring full backward compatibility.

Installation

Add accept_language to your list of dependencies in mix.exs:

def deps do
  [
    {:accept_language, "~> 0.1.0"}
  ]
end

Usage

AcceptLanguage.negotiate("da, en-GB;q=0.8, en;q=0.7", [:en, :da])
# => :da

Behavior

Quality values

Quality values (q-values) indicate relative preference, ranging from 0 (not acceptable) to 1 (most preferred). When omitted, the default is 1.

Per RFC 7231 Section 5.3.1, valid q-values have at most three decimal places: 0, 0.7, 0.85, 1.000. Invalid q-values cause the associated language range to be ignored.

AcceptLanguage.negotiate("da, en-GB;q=0.8, en;q=0.7", [:en, :da])
# => :da       (q=1 beats q=0.8)

AcceptLanguage.negotiate("da, en-GB;q=0.8, en;q=0.7", [:en, :"en-GB"])
# => :"en-GB"  (q=0.8 beats q=0.7)

AcceptLanguage.negotiate("da, en-GB;q=0.8, en;q=0.7", [:ja])
# => nil       (no match)

Declaration order

When multiple languages share the same q-value, declaration order in the header determines priority—the first declared language wins:

AcceptLanguage.negotiate("en;q=0.8, fr;q=0.8", [:en, :fr])
# => :en  (declared first)

AcceptLanguage.negotiate("fr;q=0.8, en;q=0.8", [:en, :fr])
# => :fr  (declared first)

Basic Filtering

This library implements the Basic Filtering matching scheme defined in RFC 4647 Section 3.3.1. A language range matches a language tag if, in a case-insensitive comparison, it exactly equals the tag, or if it exactly equals a prefix of the tag such that the first character following the prefix is -.

AcceptLanguage.negotiate("de-de", [:"de-DE-1996"])
# => :"de-DE-1996"  (prefix match)

AcceptLanguage.negotiate("de-de", [:"de-Deva"])
# => nil  ("de-de" is not a prefix of "de-Deva")

AcceptLanguage.negotiate("de-de", [:"de-Latn-DE"])
# => nil  ("de-de" is not a prefix of "de-Latn-DE")

Prefix matching respects hyphen boundaries:

AcceptLanguage.negotiate("zh", [:"zh-TW"])
# => :"zh-TW"  ("zh" matches "zh-TW")

AcceptLanguage.negotiate("zh", [:zhx])
# => nil  ("zh" does not match "zhx" — different language code)

AcceptLanguage.negotiate("zh-TW", [:zh])
# => nil  (more specific range does not match less specific tag)

Wildcards

The wildcard * matches any language not matched by another range in the header. This behavior is specific to HTTP, as noted in RFC 4647 Section 3.3.1.

AcceptLanguage.negotiate("de, *;q=0.5", [:ja])
# => :ja  (matched by wildcard)

AcceptLanguage.negotiate("de, *;q=0.5", [:de, :ja])
# => :de  (explicit match takes precedence)

Exclusions

A q-value of 0 explicitly marks a language as not acceptable:

AcceptLanguage.negotiate("*, en;q=0", [:en])
# => nil  (English explicitly excluded)

AcceptLanguage.negotiate("*, en;q=0", [:ja])
# => :ja  (Japanese matched by wildcard)

Exclusions apply via prefix matching:

AcceptLanguage.negotiate("*, en;q=0", [:"en-GB"])
# => nil  (en-GB excluded via "en" prefix)

Case insensitivity

Matching is case-insensitive per RFC 4647 Section 2, but the original case of available language tags is preserved in the return value:

AcceptLanguage.negotiate("EN-GB", [:"en-gb"])
# => :"en-gb"

AcceptLanguage.negotiate("en-gb", [:"EN-GB"])
# => :"EN-GB"

Defensive limits

To prevent denial-of-service via adversarial headers, the parser enforces two limits:

These thresholds are well above real-world usage (browsers typically send 2–10 ranges in under 200 bytes) and should not affect legitimate traffic.

BCP 47 language tags

Full support for BCP 47 language tags including script subtags, region subtags, and variant subtags:

# Script subtags
AcceptLanguage.negotiate("zh-Hant", [:"zh-Hant-TW", :"zh-Hans-CN"])
# => :"zh-Hant-TW"

# Variant subtags
AcceptLanguage.negotiate("de-1996, de;q=0.9", [:"de-CH-1996", :"de-CH"])
# => :"de-CH-1996"

Integration examples

Plug

defmodule MyApp.Plug.Locale do
  @behaviour Plug

  @available_locales [:en, :fr, :de]
  @default_locale :en

  @impl true
  def init(opts), do: opts

  @impl true
  def call(conn, _opts) do
    locale =
      conn
      |> Plug.Conn.get_req_header("accept-language")
      |> List.first()
      |> AcceptLanguage.negotiate(@available_locales)
      |> Kernel.||(@default_locale)

    Plug.Conn.assign(conn, :locale, locale)
  end
end

Phoenix

defmodule MyAppWeb.SetLocalePlug do
  import Plug.Conn

  @available_locales Gettext.known_locales(MyAppWeb.Gettext) |> Enum.map(&String.to_atom/1)
  @default_locale Gettext.get_locale(MyAppWeb.Gettext) |> String.to_atom()

  def init(opts), do: opts

  def call(conn, _opts) do
    locale =
      conn
      |> get_req_header("accept-language")
      |> List.first()
      |> AcceptLanguage.negotiate(@available_locales)
      |> Kernel.||(@default_locale)

    Gettext.put_locale(MyAppWeb.Gettext, Atom.to_string(locale))
    assign(conn, :locale, locale)
  end
end

Standards compliance

Supported specifications

Specification Description Status
RFC 7231 §5.3.5 Accept-Language header field ✅ Supported
RFC 7231 §5.3.1 Quality values (qvalues) ✅ Supported
RFC 4647 §2.1 Basic Language Range syntax ✅ Supported
RFC 4647 §3.3.1 Basic Filtering scheme ✅ Supported
RFC 7230 §3.2.3 OWS (optional whitespace) handling ✅ Supported
BCP 47 Language tag structure ✅ Supported

Not implemented

Specification Description Reason
RFC 4647 §2.2 Extended Language Range Not used by HTTP
RFC 4647 §3.3.2 Extended Filtering Not used by HTTP
RFC 4647 §3.4 Lookup scheme Design choice — Basic Filtering is appropriate for HTTP content negotiation

Documentation

See also

Versioning

This library follows Semantic Versioning 2.0.

License

Available as open source under the terms of the MIT License.