Skip to content

cyril/accept_language.ex

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

AcceptLanguage

CI Hex Version Hex Docs Elixir License

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:

  • Field size: headers exceeding 4096 bytes are treated as absent (returns nil)
  • Range count: at most 50 language ranges are processed; any beyond this are silently discarded

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 with Phoenix

Create a module plug that negotiates the locale from the Accept-Language header and configures Gettext for the current request.

Place the file in lib/my_app_web/plugs/locale.ex:

defmodule MyAppWeb.Plugs.Locale do
  import Plug.Conn

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

  def init(default), do: default

  def call(conn, default) do
    locale =
      conn
      |> get_req_header("accept-language")
      |> List.first()
      |> negotiate(default)

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

  defp negotiate(nil, default), do: default

  defp negotiate(header, default) do
    case AcceptLanguage.negotiate(header, @locales) do
      nil -> default
      locale -> locale
    end
  end
end

Then add it to the :browser pipeline in lib/my_app_web/router.ex:

pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_live_flash
  plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
  plug :protect_from_forgery
  plug :put_secure_browser_headers
  plug MyAppWeb.Plugs.Locale, :en
end

The default locale (:en) is passed as argument and used when the header is absent or matches none of the known locales. The list of supported locales is derived from Gettext at compile time, so adding a new translation automatically makes it available for negotiation.

Note: This plug does not depend on Phoenix — it uses only Plug.Conn and can be used in any Plug-based application. Remove the Gettext.put_locale/2 call if your application does not use Gettext.

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.

About

Lightweight, zero-dependency Elixir library for parsing the Accept-Language HTTP header. Implements RFC 4647 Basic Filtering to match user language preferences against available locales. Supports quality values, wildcards, exclusions, prefix matching, and BCP 47 tags.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages