Accept headers

A ruby library that does content negotiation and parses and sorts http accept headers

This project is maintained by kamui

Build Status Code Climate Test Coverage

AcceptHeaders

AcceptHeaders is a ruby library that does content negotiation and parses and sorts http accept headers.

Some features of the library are:

This library is optimistic when parsing headers. If a specific media type, encoding, or language can't be parsed, is in an invalid format, or contains invalid characters, it will skip that specific entry when constructing the sorted list. If a q value can't be read or is in the wrong format (more than 3 decimal places), it will default it to 0.001 so it still has a chance to match. Lack of an explicit q value of course defaults to 1.

Installation

Add this line to your application's Gemfile:

gem 'accept_headers'

And then execute:

$ bundle

Or install it yourself as:

$ gem install accept_headers

Usage

Accept

AcceptHeaders::MediaType::Negotiator is a class that is initialized with an Accept header string and will internally store an array of MediaTypes in descending order according to the spec, which takes into account q value, type/subtype and extensions specificity.

accept_header = 'Accept: text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5'
media_types = AcceptHeaders::MediaType::Negotiator.new(accept_header)

media_types.list

# Returns:

[
  AcceptHeaders::MediaType.new('text', 'html', extensions: { 'level' => '1' }),
  AcceptHeaders::MediaType.new('text', 'html', q: 0.7),
  AcceptHeaders::MediaType.new('*', '*', q: 0.5),
  AcceptHeaders::MediaType.new('text', 'html', q: 0.4, extensions: { 'level' => '2' }),
  AcceptHeaders::MediaType.new('text', '*', q: 0.3)
]

#negotiate takes an array of media range strings supported (by your API or route/controller) and returns the best supported MediaType and the extensions params from the matching internal media type.

This will first check the available list for any matching media types with a q of 0 and skip any matches. It does this because the RFC specifies that if the q value is 0, then content with this parameter is not acceptable. Then it'll look to the highest q values and look for matches in descending q value order and return the first match (accounting for wildcards). Finally, if there are no matches, it returns nil.

# The same media_types variable as above
media_types.negotiate(['text/html', 'text/plain'])

# Returns this equivalent:

AcceptHeader::MediaType.new('text', 'html', extensions: { 'level' => '1' })

It returns the matching MediaType, so you can see which one matched and also access the extensions params. For example, if you wanted to put your API version in the extensions, you could then retrieve the value.

versions_header = 'Accept: application/json;version=2,application/json;version=1;q=0.8'
media_types = AcceptHeaders::MediaType::Negotiator.new(versions_header)

m = media_types.negotiate('application/json')
puts m.extensions['version'] # returns '2'

#accept?:

media_types.accept?('text/html') # true

Accept-Encoding

AcceptHeader::Encoding::Encoding:

accept_encoding = 'Accept-Encoding: deflate; q=0.5, gzip, compress; q=0.8, identity'
encodings = AcceptHeaders::Encoding::Negotiator.new(accept_encoding)

encodings.list

# Returns:

[
  AcceptHeaders::Encoding.new('gzip'),
  AcceptHeaders::Encoding.new('compress', q: 0.8),
  AcceptHeaders::Encoding.new('deflate', q: 0.5)
]

#negotiate:

encodings.negotiate(['gzip', 'compress'])

# Returns this equivalent:

AcceptHeader::Encoding.new('gzip')

#accept?:

encodings.accept?('gzip') # true

# Identity is accepted as long as it's not explicitly rejected 'identity;q=0'

encodings.accept?('identity') # true

Accept-Language

Accept::Language::Negotiator:

accept_language = 'Accept-Language: en-*, en-us, *;q=0.8'
languages = AcceptHeaders::Language::Negotiator.new(accept_language)

languages.list

# Returns:

[
  AcceptHeaders::Language.new('en', 'us'),
  AcceptHeaders::Language.new('en', '*'),
  AcceptHeaders::Language.new('*', '*', q: 0.8)
]

#negotiate:

languages.negotiate(['en-us', 'zh-Hant'])

# Returns this equivalent:

AcceptHeaders::Language.new('en', 'us')

#accept?:

languages.accept?('en-gb') # true

Rack Middleware

Add the middleware:

require 'accept_headers/middleware'
use AcceptHeaders::Middleware
run YourApp

Simple way to set the content response headers based on the request accept headers and the supported media types, encodings, and languages provided by the app or route.

class YourApp
  def initialize(app)
    @app = app
  end

  def call(env)
    # List your arrays of supported media types, encodings, languages. This can be global or per route/controller
    supported_media_types = %w[application/json application/xml text/html text/plain]
    supported_encodings = %w[gzip identify]
    supported_languages = %w[en-US en-GB]

    # Call the Negotiators and pass in the supported arrays and it'll return the best match
    matched_media_type = env["accept_headers.media_types"].negotiate(supported_media_types)
    matched_encoding = env["accept_headers.encodings"].negotiate(supported_encodings)
    matched_language = env["accept_headers.languages"].negotiate(supported_languages)

    # Set a default, in this case an empty string, in case of a bad header that cannot be parsed
    # The return value is a MediaType, Encoding, or Language depending on the case:
    # On MediaType, you can call #type ('text'), #subtype ('html'), #media_range ('text/html') to get the stringified parts
    # On Encoding, you can call #encoding to get the string encoding ('gzip')
    # On Language, you can call #primary_tag ('en'), #subtag ('us'), or #language_tag ('en-us')
    headers = {
      'Content-Type' => matched_media_type ? matched_media_type.media_range : '',
      'Content-Encoding' => matched_encoding ? matched_encoding.encoding : '',
      'Content-Language' => matched_language ? matched_language.language_tag : '',
    }

    [200, headers, ["Hello World!"]]
  end
end

Contributing

  1. Fork it ( https://github.com/[my-github-username]/accept_headers/fork )
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request