AcceptLanguage

A lightweight, thread-safe Ruby library for parsing the Accept-Language HTTP header as defined in RFC 2616, with full support for BCP 47 language tags.

Version Yard documentation Ruby RuboCop License

Installation

gem "accept_language"

Usage

AcceptLanguage.parse("da, en-GB;q=0.8, en;q=0.7").match(:en, :da)
# => :da

Behavior

Quality values

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

parser = AcceptLanguage.parse("da, en-GB;q=0.8, en;q=0.7")

parser.match(:en, :da)      # => :da  (q=1 > q=0.8)
parser.match(:en, :"en-GB") # => :"en-GB"  (q=0.8 > q=0.7)
parser.match(:ja)           # => nil  (no match)

Per RFC 2616 Section 3.9, valid q-values have at most three decimal places: 0, 0.7, 0.85, 1.000. Invalid q-values are ignored.

Identical quality values

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

AcceptLanguage.parse("en;q=0.8, fr;q=0.8").match(:en, :fr)
# => :en  (declared first)

AcceptLanguage.parse("fr;q=0.8, en;q=0.8").match(:en, :fr)
# => :fr  (declared first)

Prefix matching

Per RFC 2616 Section 14.4, a language-range matches any language-tag that exactly equals the range or begins with the range followed by -:

AcceptLanguage.parse("zh").match(:"zh-TW")
# => :"zh-TW"  ("zh" matches "zh-TW")

AcceptLanguage.parse("zh-TW").match(:zh)
# => nil  ("zh-TW" does not match "zh")

Note that prefix matching follows hyphen boundaries—zh does not match zhx:

AcceptLanguage.parse("zh").match(:zhx)
# => nil  ("zhx" is a different language code)

Wildcards

The wildcard * matches any language not matched by another range:

AcceptLanguage.parse("de, *;q=0.5").match(:ja)
# => :ja  (matched by wildcard)

AcceptLanguage.parse("de, *;q=0.5").match(:de, :ja)
# => :de  (explicit match preferred over wildcard)

Exclusions

A q-value of 0 explicitly excludes a language:

AcceptLanguage.parse("*, en;q=0").match(:en)
# => nil  (English excluded)

AcceptLanguage.parse("*, en;q=0").match(:ja)
# => :ja  (matched by wildcard)

Exclusions apply to prefix matches:

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

Case insensitivity

Matching is case-insensitive per RFC 2616, but the original case of available language tags is preserved:

AcceptLanguage.parse("EN-GB").match(:"en-gb")
# => :"en-gb"

AcceptLanguage.parse("en-gb").match(:"EN-GB")
# => :"EN-GB"

BCP 47 language tags

Full support for BCP 47 language tags:

# Script subtags
AcceptLanguage.parse("zh-Hant").match(:"zh-Hant-TW", :"zh-Hans-CN")
# => :"zh-Hant-TW"

# Variant subtags
AcceptLanguage.parse("de-1996, de;q=0.9").match(:"de-CH-1996", :"de-CH")
# => :"de-CH-1996"

Integration examples

Rack

# config.ru
class LocaleMiddleware
  def initialize(app, available_locales:, default_locale:)
    @app = app
    @available_locales = available_locales
    @default_locale = default_locale
  end

  def call(env)
    locale = detect_locale(env) || @default_locale
    env["rack.locale"] = locale
    @app.call(env)
  end

  private

  def detect_locale(env)
    header = env["HTTP_ACCEPT_LANGUAGE"]
    return unless header

    AcceptLanguage.parse(header).match(*@available_locales)
  end
end

Ruby on Rails

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :best_locale_from_request!

  def best_locale_from_request!
    I18n.locale = best_locale_from_request
  end

  def best_locale_from_request
    # HTTP_ACCEPT_LANGUAGE is the standardized key for the Accept-Language header in Rack/Rails
    return I18n.default_locale unless request.headers.key?("HTTP_ACCEPT_LANGUAGE")

    string = request.headers.fetch("HTTP_ACCEPT_LANGUAGE")
    locale = AcceptLanguage.parse(string).match(*I18n.available_locales)

    # If the server cannot serve any matching language,
    # it can theoretically send back a 406 (Not Acceptable) error code.
    # But, for a better user experience, this is rarely done and more
    # common way is to ignore the Accept-Language header in this case.
    return I18n.default_locale if locale.nil?

    locale
  end
end

Documentation

Versioning

This library follows Semantic Versioning 2.0.

License

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