GitHub – cyril/accept_language.rb: A lightweight, thread-safe Ruby library for parsing Accept-Language HTTP headers as defined in RFC 2616.

🚀 Explore this awesome post from Hacker News 📖

📂 **Category**:

✅ **What You’ll Learn**:

A lightweight, thread-safe Ruby 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.

Version
Yard documentation
Ruby
RuboCop
License

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

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.

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

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

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

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)

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.parse("de-de").match(:"de-DE-1996")
# => :"de-DE-1996"  (prefix match)

AcceptLanguage.parse("de-de").match(:"de-Deva")
# => nil  ("de-de" is not a prefix of "de-Deva")

AcceptLanguage.parse("de-de").match(:"de-Latn-DE")
# => nil  ("de-de" is not a prefix of "de-Latn-DE")

Prefix matching respects hyphen boundaries:

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

AcceptLanguage.parse("zh").match(:zhx)
# => nil  ("zh" does not match "zhx" — different language code)

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

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.parse("de, *;q=0.5").match(:ja)
# => :ja  (matched by wildcard)

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

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

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

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

Exclusions apply via prefix matching:

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

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

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

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

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

# 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"
# 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
# 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

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
BCP 47 Language tag structure ✅ Supported

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

This library follows Semantic Versioning 2.0.

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

💬 **What’s your take?**
Share your thoughts in the comments below!

#️⃣ **#GitHub #cyrilaccept_language.rb #lightweight #threadsafe #Ruby #library #parsing #AcceptLanguage #HTTP #headers #defined #RFC**

🕒 **Posted on**: 1769341823

🌟 **Want more?** Click here for more info! 🌟

By

Leave a Reply

Your email address will not be published. Required fields are marked *