├── .editorconfig ├── .github └── workflows │ ├── coverage.yaml │ ├── documentation.yaml │ ├── test-external.yaml │ └── test.yaml ├── .gitignore ├── config └── sus.rb ├── gems.rb ├── http-accept.gemspec ├── lib └── http │ ├── accept.rb │ └── accept │ ├── charsets.rb │ ├── content_type.rb │ ├── encodings.rb │ ├── languages.rb │ ├── media_types.rb │ ├── media_types │ └── map.rb │ ├── parse_error.rb │ ├── quoted_string.rb │ ├── sort.rb │ └── version.rb ├── license.md ├── readme.md ├── release.cert └── test └── http ├── browser.rb ├── charsets.rb ├── content_type.rb ├── encodings.rb ├── frozen.rb ├── languages.rb ├── media_types.rb ├── media_types └── map.rb ├── negotiation.rb └── quoted_string.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 2 6 | 7 | [*.{yml,yaml}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yaml: -------------------------------------------------------------------------------- 1 | name: Coverage 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | COVERAGE: PartialSummary 11 | 12 | jobs: 13 | test: 14 | name: ${{matrix.ruby}} on ${{matrix.os}} 15 | runs-on: ${{matrix.os}}-latest 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.3" 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{matrix.ruby}} 31 | bundler-cache: true 32 | 33 | - name: Run tests 34 | timeout-minutes: 5 35 | run: bundle exec bake test 36 | 37 | - uses: actions/upload-artifact@v3 38 | with: 39 | name: coverage-${{matrix.os}}-${{matrix.ruby}} 40 | path: .covered.db 41 | 42 | validate: 43 | needs: test 44 | runs-on: ubuntu-latest 45 | 46 | steps: 47 | - uses: actions/checkout@v4 48 | - uses: ruby/setup-ruby@v1 49 | with: 50 | ruby-version: "3.3" 51 | bundler-cache: true 52 | 53 | - uses: actions/download-artifact@v3 54 | 55 | - name: Validate coverage 56 | timeout-minutes: 5 57 | run: bundle exec bake covered:validate --paths */.covered.db \; 58 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages: 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow one concurrent deployment: 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: true 18 | 19 | env: 20 | CONSOLE_OUTPUT: XTerm 21 | BUNDLE_WITH: maintenance 22 | 23 | jobs: 24 | generate: 25 | runs-on: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: "3.3" 33 | bundler-cache: true 34 | 35 | - name: Installing packages 36 | run: sudo apt-get install wget 37 | 38 | - name: Generate documentation 39 | timeout-minutes: 5 40 | run: bundle exec bake utopia:project:static --force no 41 | 42 | - name: Upload documentation artifact 43 | uses: actions/upload-pages-artifact@v2 44 | with: 45 | path: docs 46 | 47 | deploy: 48 | runs-on: ubuntu-latest 49 | 50 | environment: 51 | name: github-pages 52 | url: ${{steps.deployment.outputs.page_url}} 53 | 54 | needs: generate 55 | steps: 56 | - name: Deploy to GitHub Pages 57 | id: deployment 58 | uses: actions/deploy-pages@v3 59 | -------------------------------------------------------------------------------- /.github/workflows/test-external.yaml: -------------------------------------------------------------------------------- 1 | name: Test External 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | 16 | strategy: 17 | matrix: 18 | os: 19 | - ubuntu 20 | - macos 21 | 22 | ruby: 23 | - "3.0" 24 | - "3.1" 25 | - "3.2" 26 | - "3.3" 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{matrix.ruby}} 33 | bundler-cache: true 34 | 35 | - name: Run tests 36 | timeout-minutes: 10 37 | run: bundle exec bake test:external 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | env: 9 | CONSOLE_OUTPUT: XTerm 10 | 11 | jobs: 12 | test: 13 | name: ${{matrix.ruby}} on ${{matrix.os}} 14 | runs-on: ${{matrix.os}}-latest 15 | continue-on-error: ${{matrix.experimental}} 16 | 17 | strategy: 18 | matrix: 19 | os: 20 | - ubuntu 21 | - macos 22 | 23 | ruby: 24 | - "3.0" 25 | - "3.1" 26 | - "3.2" 27 | - "3.3" 28 | 29 | experimental: [false] 30 | 31 | include: 32 | - os: ubuntu 33 | ruby: truffleruby 34 | experimental: true 35 | - os: ubuntu 36 | ruby: jruby 37 | experimental: true 38 | - os: ubuntu 39 | ruby: head 40 | experimental: true 41 | 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: ruby/setup-ruby@v1 45 | with: 46 | ruby-version: ${{matrix.ruby}} 47 | bundler-cache: true 48 | 49 | - name: Run tests 50 | timeout-minutes: 10 51 | run: bundle exec bake test 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /pkg/ 3 | /gems.locked 4 | /.covered.db 5 | /external 6 | -------------------------------------------------------------------------------- /config/sus.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2024, by Samuel Williams. 5 | 6 | require 'covered/sus' 7 | include Covered::Sus 8 | -------------------------------------------------------------------------------- /gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2022-2024, by Samuel Williams. 5 | 6 | source "https://rubygems.org" 7 | 8 | gemspec 9 | 10 | group :maintenance, optional: true do 11 | gem "bake-modernize" 12 | gem "bake-gem" 13 | 14 | gem "utopia-project" 15 | end 16 | 17 | group :test do 18 | gem "bake-test" 19 | gem "bake-test-external" 20 | 21 | gem "sus" 22 | gem "covered" 23 | end 24 | -------------------------------------------------------------------------------- /http-accept.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/http/accept/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "http-accept" 7 | spec.version = HTTP::Accept::VERSION 8 | 9 | spec.summary = "Parse Accept and Accept-Language HTTP headers." 10 | spec.authors = ["Samuel Williams", "Matthew Kerwin", "Alif Rachmawadi", "Andy Brody", "Ian Oxley", "Khaled Hassan Hussein", "Olle Jonsson", "Robert Pritzkow"] 11 | spec.license = "MIT" 12 | 13 | spec.cert_chain = ['release.cert'] 14 | spec.signing_key = File.expand_path('~/.gem/release.pem') 15 | 16 | spec.homepage = "https://github.com/ioquatix/http-accept" 17 | 18 | spec.metadata = { 19 | "funding_uri" => "https://github.com/sponsors/ioquatix/", 20 | } 21 | 22 | spec.files = Dir.glob(['{lib}/**/*', '*.md'], File::FNM_DOTMATCH, base: __dir__) 23 | 24 | spec.required_ruby_version = ">= 3.0" 25 | end 26 | -------------------------------------------------------------------------------- /lib/http/accept.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | # Copyright, 2016, by Matthew Kerwin. 6 | # Copyright, 2017, by Andy Brody. 7 | 8 | require_relative 'accept/version' 9 | 10 | # Accept: header 11 | require_relative 'accept/media_types' 12 | require_relative 'accept/content_type' 13 | 14 | # Accept-Encoding: header 15 | require_relative 'accept/encodings' 16 | 17 | # Accept-Language: header 18 | require_relative 'accept/languages' 19 | 20 | module HTTP 21 | module Accept 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/http/accept/charsets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016, by Matthew Kerwin. 5 | # Copyright, 2017-2024, by Samuel Williams. 6 | 7 | require 'strscan' 8 | 9 | require_relative 'parse_error' 10 | require_relative 'quoted_string' 11 | require_relative 'sort' 12 | 13 | module HTTP 14 | module Accept 15 | module Charsets 16 | # https://tools.ietf.org/html/rfc7231#section-5.3.1 17 | QVALUE = /0(\.[0-9]{0,3})?|1(\.[0]{0,3})?/ 18 | 19 | # https://tools.ietf.org/html/rfc7231#section-5.3.3 20 | CHARSETS = /(?#{TOKEN})(;q=(?#{QVALUE}))?/ 21 | 22 | Charset = Struct.new(:charset, :q) do 23 | def quality_factor 24 | (q || 1.0).to_f 25 | end 26 | 27 | def self.parse(scanner) 28 | return to_enum(:parse, scanner) unless block_given? 29 | 30 | while scanner.scan(CHARSETS) 31 | yield self.new(scanner[:charset], scanner[:q]) 32 | 33 | # Are there more? 34 | break unless scanner.scan(/\s*,\s*/) 35 | end 36 | 37 | raise ParseError.new('Could not parse entire string!') unless scanner.eos? 38 | end 39 | end 40 | 41 | def self.parse(text) 42 | scanner = StringScanner.new(text) 43 | 44 | charsets = Charset.parse(scanner) 45 | 46 | return Sort.by_quality_factor(charsets) 47 | end 48 | 49 | HTTP_ACCEPT_CHARSET = 'HTTP_ACCEPT_CHARSET'.freeze 50 | WILDCARD_CHARSET = Charset.new('*', nil).freeze 51 | 52 | # Parse the list of browser preferred charsets and return ordered by priority. 53 | def self.browser_preferred_charsets(env) 54 | if accept_charsets = env[HTTP_ACCEPT_CHARSET]&.strip 55 | if accept_charsets.empty? 56 | # https://tools.ietf.org/html/rfc7231#section-5.3.3 : 57 | # 58 | # Accept-Charset = 1#( ( charset / "*" ) [ weight ] ) 59 | # 60 | # Because of the `1#` rule, an empty header value is not considered valid. 61 | raise ParseError.new('Could not parse entire string!') 62 | else 63 | return HTTP::Accept::Charsets.parse(accept_charsets) 64 | end 65 | end 66 | 67 | # "A request without any Accept-Charset header field implies that the 68 | # user agent will accept any charset in response." 69 | return [WILDCARD_CHARSET] 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/http/accept/content_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | require_relative 'media_types' 7 | require_relative 'quoted_string' 8 | 9 | module HTTP 10 | module Accept 11 | # A content type is different from a media range, in that a content type should not have any wild cards. 12 | class ContentType < MediaTypes::MediaRange 13 | def initialize(type, subtype, parameters = {}) 14 | # We do some basic validation here: 15 | raise ArgumentError.new("#{self.class} can not have wildcards: #{type}", "#{subtype}") if type.include?('*') || subtype.include?('*') 16 | 17 | super 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/http/accept/encodings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016, by Matthew Kerwin. 5 | # Copyright, 2017-2024, by Samuel Williams. 6 | 7 | require 'strscan' 8 | 9 | require_relative 'parse_error' 10 | require_relative 'quoted_string' 11 | require_relative 'sort' 12 | 13 | module HTTP 14 | module Accept 15 | module Encodings 16 | # https://tools.ietf.org/html/rfc7231#section-5.3.4 17 | CONTENT_CODING = TOKEN 18 | 19 | # https://tools.ietf.org/html/rfc7231#section-5.3.1 20 | QVALUE = /0(\.[0-9]{0,3})?|1(\.[0]{0,3})?/ 21 | 22 | CODINGS = /(?#{CONTENT_CODING})(;q=(?#{QVALUE}))?/ 23 | 24 | ContentCoding = Struct.new(:encoding, :q) do 25 | def quality_factor 26 | (q || 1.0).to_f 27 | end 28 | 29 | def self.parse(scanner) 30 | return to_enum(:parse, scanner) unless block_given? 31 | 32 | while scanner.scan(CODINGS) 33 | yield self.new(scanner[:encoding], scanner[:q]) 34 | 35 | # Are there more? 36 | break unless scanner.scan(/\s*,\s*/) 37 | end 38 | 39 | raise ParseError.new('Could not parse entire string!') unless scanner.eos? 40 | end 41 | end 42 | 43 | def self.parse(text) 44 | scanner = StringScanner.new(text) 45 | 46 | encodings = ContentCoding.parse(scanner) 47 | 48 | return Sort.by_quality_factor(encodings) 49 | end 50 | 51 | HTTP_ACCEPT_ENCODING = 'HTTP_ACCEPT_ENCODING'.freeze 52 | WILDCARD_CONTENT_CODING = ContentCoding.new('*', nil).freeze 53 | IDENTITY_CONTENT_CODING = ContentCoding.new('identity', nil).freeze 54 | 55 | # Parse the list of browser preferred content codings and return ordered by priority. If no 56 | # `Accept-Encoding:` header is specified, the behaviour is the same as if 57 | # `Accept-Encoding: *` was provided, and if a blank `Accept-Encoding:` header value is 58 | # specified, the behaviour is the same as if `Accept-Encoding: identity` was provided 59 | # (according to RFC). 60 | def self.browser_preferred_content_codings(env) 61 | if accept_content_codings = env[HTTP_ACCEPT_ENCODING]&.strip 62 | if accept_content_codings.empty? 63 | # "An Accept-Encoding header field with a combined field-value that is 64 | # empty implies that the user agent does not want any content-coding in 65 | # response." 66 | return [IDENTITY_CONTENT_CODING] 67 | else 68 | return HTTP::Accept::Encodings.parse(accept_content_codings) 69 | end 70 | end 71 | 72 | # "If no Accept-Encoding field is in the request, any content-coding 73 | # is considered acceptable by the user agent." 74 | return [WILDCARD_CONTENT_CODING] 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/http/accept/languages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | # Copyright, 2021, by Khaled Hassan Hussein. 6 | 7 | require 'strscan' 8 | 9 | require_relative 'parse_error' 10 | require_relative 'sort' 11 | 12 | module HTTP 13 | module Accept 14 | module Languages 15 | # https://tools.ietf.org/html/rfc3066#section-2.1 16 | LOCALE = /\*|[A-Z]{1,8}(-[A-Z0-9]{1,8})*/i 17 | 18 | # https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.9 19 | QVALUE = /0(\.[0-9]{0,6})?|1(\.[0]{0,6})?/ 20 | 21 | # https://greenbytes.de/tech/webdav/rfc7231.html#quality.values 22 | LANGUAGE_RANGE = /(?#{LOCALE})(\s*;\s*q=(?#{QVALUE}))?/ 23 | 24 | # Provides an efficient data-structure for matching the Accept-Languages header to set of available locales according to https://tools.ietf.org/html/rfc7231#section-5.3.5 and https://tools.ietf.org/html/rfc4647#section-2.3 25 | class Locales 26 | def self.expand(locale, into) 27 | parts = locale.split('-') 28 | 29 | while parts.size > 0 30 | key = parts.join('-') 31 | 32 | into[key] ||= locale 33 | 34 | parts.pop 35 | end 36 | end 37 | 38 | def initialize(names) 39 | @names = names 40 | @patterns = {} 41 | 42 | @names.each{|name| self.class.expand(name, @patterns)} 43 | 44 | self.freeze 45 | end 46 | 47 | def freeze 48 | @names.freeze 49 | @patterns.freeze 50 | 51 | super 52 | end 53 | 54 | def each(&block) 55 | return to_enum unless block_given? 56 | 57 | @names.each(&block) 58 | end 59 | 60 | attr :names 61 | attr :patterns 62 | 63 | # Returns the intersection of others retaining order. 64 | def & languages 65 | languages.collect{|language_range| @patterns[language_range.locale]}.compact 66 | end 67 | 68 | def include? locale_name 69 | @patterns.include? locale_name 70 | end 71 | 72 | def join(*args) 73 | @names.join(*args) 74 | end 75 | 76 | def + others 77 | self.class.new(@names + others.to_a) 78 | end 79 | 80 | def to_a 81 | @names 82 | end 83 | end 84 | 85 | LanguageRange = Struct.new(:locale, :q) do 86 | def quality_factor 87 | (q || 1.0).to_f 88 | end 89 | 90 | def self.parse(scanner) 91 | return to_enum(:parse, scanner) unless block_given? 92 | 93 | while scanner.scan(LANGUAGE_RANGE) 94 | yield self.new(scanner[:locale], scanner[:q]) 95 | 96 | # Are there more? 97 | break unless scanner.scan(/\s*,\s*/) 98 | end 99 | 100 | raise ParseError.new("Could not parse entire string!") unless scanner.eos? 101 | end 102 | end 103 | 104 | def self.parse(text) 105 | scanner = StringScanner.new(text) 106 | 107 | languages = LanguageRange.parse(scanner) 108 | 109 | return Sort.by_quality_factor(languages) 110 | end 111 | end 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /lib/http/accept/media_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | require 'strscan' 7 | 8 | require_relative 'parse_error' 9 | require_relative 'quoted_string' 10 | require_relative 'sort' 11 | 12 | require_relative 'media_types/map' 13 | 14 | module HTTP 15 | module Accept 16 | # Parse and process the HTTP Accept: header. 17 | module MediaTypes 18 | # According to https://tools.ietf.org/html/rfc7231#section-5.3.2 19 | MIME_TYPE = /(?#{TOKEN})\/(?#{TOKEN})/ 20 | PARAMETER = /\s*;\s*(?#{TOKEN})=((?#{TOKEN})|(?#{QUOTED_STRING}))/ 21 | 22 | # A single entry in the Accept: header, which includes a mime type and associated parameters. 23 | MediaRange = Struct.new(:type, :subtype, :parameters) do 24 | def initialize(type, subtype = '*', parameters = {}) 25 | super(type, subtype, parameters) 26 | end 27 | 28 | def parameters_string 29 | return '' if parameters == nil or parameters.empty? 30 | 31 | parameters.collect do |key, value| 32 | "; #{key.to_s}=#{QuotedString.quote(value.to_s)}" 33 | end.join 34 | end 35 | 36 | def === other 37 | if other.is_a? self.class 38 | super 39 | else 40 | return self.mime_type === other 41 | end 42 | end 43 | 44 | def mime_type 45 | "#{type}/#{subtype}" 46 | end 47 | 48 | def to_s 49 | "#{type}/#{subtype}#{parameters_string}" 50 | end 51 | 52 | alias to_str to_s 53 | 54 | def quality_factor 55 | parameters.fetch('q', 1.0).to_f 56 | end 57 | 58 | def split(*args) 59 | return [type, subtype] 60 | end 61 | 62 | def self.parse_parameters(scanner, normalize_whitespace) 63 | parameters = {} 64 | 65 | while scanner.scan(PARAMETER) 66 | key = scanner[:key] 67 | 68 | # If the regular expression PARAMETER matched, it must be one of these two: 69 | if value = scanner[:value] 70 | parameters[key] = value 71 | elsif quoted_value = scanner[:quoted_value] 72 | parameters[key] = QuotedString.unquote(quoted_value, normalize_whitespace) 73 | end 74 | end 75 | 76 | return parameters 77 | end 78 | 79 | def self.parse(scanner, normalize_whitespace = true) 80 | return to_enum(:parse, scanner, normalize_whitespace) unless block_given? 81 | 82 | while scanner.scan(MIME_TYPE) 83 | type = scanner[:type] 84 | subtype = scanner[:subtype] 85 | 86 | parameters = parse_parameters(scanner, normalize_whitespace) 87 | 88 | yield self.new(type, subtype, parameters) 89 | 90 | # Are there more? 91 | break unless scanner.scan(/\s*,\s*/) 92 | end 93 | 94 | raise ParseError.new("Could not parse entire string!") unless scanner.eos? 95 | end 96 | end 97 | 98 | def self.parse(text, normalize_whitespace = true) 99 | scanner = StringScanner.new(text) 100 | 101 | media_types = MediaRange.parse(scanner, normalize_whitespace) 102 | 103 | return Sort.by_quality_factor(media_types) 104 | end 105 | 106 | HTTP_ACCEPT = 'HTTP_ACCEPT'.freeze 107 | WILDCARD_MEDIA_RANGE = MediaRange.new("*", "*", {}).freeze 108 | 109 | # Parse the list of browser preferred content types and return ordered by priority. If no `Accept:` header is specified, the behaviour is the same as if `Accept: */*` was provided (according to RFC). 110 | def self.browser_preferred_media_types(env) 111 | if accept_content_types = env[HTTP_ACCEPT]&.strip 112 | unless accept_content_types.empty? 113 | return HTTP::Accept::MediaTypes.parse(accept_content_types) 114 | end 115 | end 116 | 117 | # According to http://tools.ietf.org/html/rfc7231#section-5.3.2: 118 | # A request without any Accept header field implies that the user agent will accept any media type in response. 119 | # You should treat a non-existent Accept header as */*. 120 | return [WILDCARD_MEDIA_RANGE] 121 | end 122 | end 123 | end 124 | end 125 | 126 | -------------------------------------------------------------------------------- /lib/http/accept/media_types/map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | module HTTP 7 | module Accept 8 | module MediaTypes 9 | # Map a set of mime types to objects. 10 | class Map 11 | WILDCARD = "*/*".freeze 12 | 13 | def initialize 14 | @media_types = {} 15 | end 16 | 17 | def freeze 18 | unless frozen? 19 | @media_types.freeze 20 | @media_types.each{|key,value| value.freeze} 21 | 22 | super 23 | end 24 | end 25 | 26 | # Given a list of content types (e.g. from browser_preferred_content_types), return the best converter. Media types can be an array of MediaRange or String values. 27 | def for(media_types) 28 | media_types.each do |media_range| 29 | mime_type = case media_range 30 | when String then media_range 31 | else media_range.mime_type 32 | end 33 | 34 | if object = @media_types[mime_type] 35 | return object, media_range 36 | end 37 | end 38 | 39 | return nil 40 | end 41 | 42 | def []= media_range, object 43 | @media_types[media_range] = object 44 | end 45 | 46 | def [] media_range 47 | @media_types[media_range] 48 | end 49 | 50 | # Add a converter to the collection. A converter can be anything that responds to #content_type. Objects will be considered in the order they are added, subsequent objects cannot override previously defined media types. `object` must respond to #split('/', 2) which should give the type and subtype. 51 | def << object 52 | type, subtype = object.split('/', 2) 53 | 54 | # We set the default if not specified already: 55 | @media_types[WILDCARD] = object if @media_types.empty? 56 | 57 | if type != '*' 58 | @media_types["#{type}/*"] ||= object 59 | 60 | if subtype != '*' 61 | @media_types["#{type}/#{subtype}"] ||= object 62 | end 63 | end 64 | 65 | return self 66 | end 67 | end 68 | end 69 | end 70 | end 71 | 72 | -------------------------------------------------------------------------------- /lib/http/accept/parse_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | module HTTP 7 | module Accept 8 | class ParseError < ArgumentError 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/http/accept/quoted_string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | module HTTP 7 | module Accept 8 | # According to https://tools.ietf.org/html/rfc7231#appendix-C 9 | TOKEN = /[!#$%&'*+\-.^_`|~0-9A-Z]+/i 10 | QUOTED_STRING = /"(?:.(?!(? '1'} 50 | expect(media_types[1].mime_type).to be == "text/html" 51 | expect(media_types[1].parameters).to be == {'q' => '0.5'} 52 | ``` 53 | 54 | Normally, you'd want to match the media types against some set of available mime types: 55 | 56 | ``` ruby 57 | module ToJSON 58 | def content_type 59 | HTTP::Accept::ContentType.new("application", "json", charset: 'utf-8') 60 | end 61 | 62 | # Used for inserting into map. 63 | def split(*args) 64 | content_type.split(*args) 65 | end 66 | 67 | def convert(object, options) 68 | object.to_json 69 | end 70 | end 71 | 72 | module ToXML 73 | # Are you kidding? 74 | end 75 | 76 | map = HTTP::Accept::MediaTypes::Map.new 77 | map << ToJSON 78 | map << ToXML 79 | 80 | object, media_range = map.for(media_types) 81 | content = object.convert(model, media_range.parameters) 82 | response = [200, {'Content-Type' => object.content_type}, [content]] 83 | ``` 84 | 85 | ### Parsing Accept-Language: headers 86 | 87 | You can parse the incoming `Accept-Language:` header: 88 | 89 | ``` ruby 90 | languages = HTTP::Accept::Languages.parse("da, en-gb;q=0.8, en;q=0.7") 91 | 92 | expect(languages[0].locale).to be == "da" 93 | expect(languages[1].locale).to be == "en-gb" 94 | expect(languages[2].locale).to be == "en" 95 | ``` 96 | 97 | Normally, you'd want to match the languages against some set of available localizations: 98 | 99 | ``` ruby 100 | available_localizations = HTTP::Accept::Languages::Locales.new(["en-nz", "en-us"]) 101 | 102 | # Given the languages that the user wants, and the localizations available, compute the set of desired localizations. 103 | desired_localizations = available_localizations & languages 104 | ``` 105 | 106 | The `desired_localizations` in the example above is a subset of `available_localizations`. 107 | 108 | `HTTP::Accept::Languages::Locales` provides an efficient data-structure for matching the Accept-Languages header to set of available localizations according to and 109 | 110 | ## Contributing 111 | 112 | We welcome contributions to this project. 113 | 114 | 1. Fork it. 115 | 2. Create your feature branch (`git checkout -b my-new-feature`). 116 | 3. Commit your changes (`git commit -am 'Add some feature'`). 117 | 4. Push to the branch (`git push origin my-new-feature`). 118 | 5. Create new Pull Request. 119 | 120 | ### Developer Certificate of Origin 121 | 122 | This project uses the [Developer Certificate of Origin](https://developercertificate.org/). All contributors to this project must agree to this document to have their contributions accepted. 123 | 124 | ### Contributor Covenant 125 | 126 | This project is governed by the [Contributor Covenant](https://www.contributor-covenant.org/). All contributors and participants agree to abide by its terms. 127 | -------------------------------------------------------------------------------- /release.cert: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIE2DCCA0CgAwIBAgIBATANBgkqhkiG9w0BAQsFADBhMRgwFgYDVQQDDA9zYW11 3 | ZWwud2lsbGlhbXMxHTAbBgoJkiaJk/IsZAEZFg1vcmlvbnRyYW5zZmVyMRIwEAYK 4 | CZImiZPyLGQBGRYCY28xEjAQBgoJkiaJk/IsZAEZFgJuejAeFw0yMjA4MDYwNDUz 5 | MjRaFw0zMjA4MDMwNDUzMjRaMGExGDAWBgNVBAMMD3NhbXVlbC53aWxsaWFtczEd 6 | MBsGCgmSJomT8ixkARkWDW9yaW9udHJhbnNmZXIxEjAQBgoJkiaJk/IsZAEZFgJj 7 | bzESMBAGCgmSJomT8ixkARkWAm56MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIB 8 | igKCAYEAomvSopQXQ24+9DBB6I6jxRI2auu3VVb4nOjmmHq7XWM4u3HL+pni63X2 9 | 9qZdoq9xt7H+RPbwL28LDpDNflYQXoOhoVhQ37Pjn9YDjl8/4/9xa9+NUpl9XDIW 10 | sGkaOY0eqsQm1pEWkHJr3zn/fxoKPZPfaJOglovdxf7dgsHz67Xgd/ka+Wo1YqoE 11 | e5AUKRwUuvaUaumAKgPH+4E4oiLXI4T1Ff5Q7xxv6yXvHuYtlMHhYfgNn8iiW8WN 12 | XibYXPNP7NtieSQqwR/xM6IRSoyXKuS+ZNGDPUUGk8RoiV/xvVN4LrVm9upSc0ss 13 | RZ6qwOQmXCo/lLcDUxJAgG95cPw//sI00tZan75VgsGzSWAOdjQpFM0l4dxvKwHn 14 | tUeT3ZsAgt0JnGqNm2Bkz81kG4A2hSyFZTFA8vZGhp+hz+8Q573tAR89y9YJBdYM 15 | zp0FM4zwMNEUwgfRzv1tEVVUEXmoFCyhzonUUw4nE4CFu/sE3ffhjKcXcY//qiSW 16 | xm4erY3XAgMBAAGjgZowgZcwCQYDVR0TBAIwADALBgNVHQ8EBAMCBLAwHQYDVR0O 17 | BBYEFO9t7XWuFf2SKLmuijgqR4sGDlRsMC4GA1UdEQQnMCWBI3NhbXVlbC53aWxs 18 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MC4GA1UdEgQnMCWBI3NhbXVlbC53aWxs 19 | aWFtc0BvcmlvbnRyYW5zZmVyLmNvLm56MA0GCSqGSIb3DQEBCwUAA4IBgQB5sxkE 20 | cBsSYwK6fYpM+hA5B5yZY2+L0Z+27jF1pWGgbhPH8/FjjBLVn+VFok3CDpRqwXCl 21 | xCO40JEkKdznNy2avOMra6PFiQyOE74kCtv7P+Fdc+FhgqI5lMon6tt9rNeXmnW/ 22 | c1NaMRdxy999hmRGzUSFjozcCwxpy/LwabxtdXwXgSay4mQ32EDjqR1TixS1+smp 23 | 8C/NCWgpIfzpHGJsjvmH2wAfKtTTqB9CVKLCWEnCHyCaRVuKkrKjqhYCdmMBqCws 24 | JkxfQWC+jBVeG9ZtPhQgZpfhvh+6hMhraUYRQ6XGyvBqEUe+yo6DKIT3MtGE2+CP 25 | eX9i9ZWBydWb8/rvmwmX2kkcBbX0hZS1rcR593hGc61JR6lvkGYQ2MYskBveyaxt 26 | Q2K9NVun/S785AP05vKkXZEFYxqG6EW012U4oLcFl5MySFajYXRYbuUpH6AY+HP8 27 | voD0MPg1DssDLKwXyt1eKD/+Fq0bFWhwVM/1XiAXL7lyYUyOq24KHgQ2Csg= 28 | -----END CERTIFICATE----- 29 | -------------------------------------------------------------------------------- /test/http/browser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | require 'http/accept/media_types' 7 | require 'http/accept/content_type' 8 | 9 | ServerContextTypes = Sus::Shared("server content types") do 10 | let(:json_content_type) {HTTP::Accept::ContentType.new("application", "json")} 11 | let(:html_content_type) {HTTP::Accept::ContentType.new("text", "html")} 12 | let(:wildcard_media_range) {HTTP::Accept::MediaTypes::MediaRange.new("*", "*")} 13 | 14 | let(:map) {HTTP::Accept::MediaTypes::Map.new} 15 | let(:media_types) {HTTP::Accept::MediaTypes.parse(accept_header)} 16 | end 17 | 18 | AWebBrowser = Sus::Shared("a web browser") do 19 | include_context ServerContextTypes 20 | 21 | it "should match text/html" do 22 | map << html_content_type 23 | map << json_content_type 24 | 25 | object, _ = map.for(media_types) 26 | 27 | expect(object).to be == html_content_type 28 | end 29 | end 30 | 31 | describe "Firefox Accept: headers" do 32 | let(:accept_header) {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"} 33 | it_behaves_like AWebBrowser 34 | end 35 | 36 | describe "WebKit Accept: headers" do 37 | let(:accept_header) {"application/xml,application/xhtml+xml,text/html;q=0.9, text/plain;q=0.8,image/png,*/*;q=0.5"} 38 | it_behaves_like AWebBrowser 39 | end 40 | 41 | describe "Safari 5 Accept: headers" do 42 | let(:accept_header) {"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"} 43 | it_behaves_like AWebBrowser 44 | end 45 | 46 | describe "Internet Explorer 8 Accept: headers" do 47 | # http://stackoverflow.com/questions/1670329/ie-accept-headers-changing-why 48 | let(:accept_header) {"image/jpeg, application/x-ms-application, image/gif, application/xaml+xml, image/pjpeg, application/x-ms-xbap, application/x-shockwave-flash, application/msword, */*"} 49 | it_behaves_like AWebBrowser 50 | end 51 | 52 | describe "Opera Accept: headers" do 53 | let(:accept_header) {"text/html, application/xml;q=0.9, application/xhtml+xml, image/png, image/webp, image/jpeg, image/gif, image/x-xbitmap, */*;q=0.1"} 54 | it_behaves_like AWebBrowser 55 | end 56 | 57 | describe "XMLHttpRequest Accept: headers" do 58 | let(:accept_header) {"application/json"} 59 | 60 | include_context ServerContextTypes 61 | 62 | it "should match application/json" do 63 | map << json_content_type 64 | 65 | object, _ = map.for(media_types) 66 | 67 | expect(object).to be == json_content_type 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/http/charsets.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016, by Matthew Kerwin. 5 | # Copyright, 2017-2024, by Samuel Williams. 6 | 7 | require 'http/accept/charsets' 8 | 9 | describe HTTP::Accept::Charsets::Charset do 10 | it "should have default quality_factor of 1.0" do 11 | charset = HTTP::Accept::Charsets::Charset.new('utf-8', nil) 12 | expect(charset.quality_factor).to be == 1.0 13 | end 14 | end 15 | 16 | describe HTTP::Accept::Charsets do 17 | it "should parse basic header" do 18 | charsets = HTTP::Accept::Charsets.parse("utf-8, iso-8859-1;q=0.5, windows-1252;q=0.25") 19 | 20 | expect(charsets.length).to be == 3 21 | 22 | expect(charsets[0].charset).to be == "utf-8" 23 | expect(charsets[0].quality_factor).to be == 1.0 24 | 25 | expect(charsets[1].charset).to be == "iso-8859-1" 26 | expect(charsets[1].quality_factor).to be == 0.5 27 | 28 | expect(charsets[2].charset).to be == "windows-1252" 29 | expect(charsets[2].quality_factor).to be == 0.25 30 | end 31 | 32 | it "should order based on quality factor" do 33 | charsets = HTTP::Accept::Charsets.parse("windows-1252;q=0.25, iso-8859-1;q=0.5, utf-8") 34 | expect(charsets.collect(&:charset)).to be == %w{utf-8 iso-8859-1 windows-1252} 35 | 36 | charsets = HTTP::Accept::Charsets.parse("us-ascii,iso-8859-1;q=0.8,windows-1252;q=0.6,utf-8") 37 | expect(charsets.collect(&:charset)).to be == %w{us-ascii utf-8 iso-8859-1 windows-1252} 38 | end 39 | 40 | it "should accept wildcard charset" do 41 | charsets = HTTP::Accept::Charsets.parse("*;q=0") 42 | 43 | expect(charsets[0].charset).to be == "*" 44 | expect(charsets[0].quality_factor).to be == 0 45 | end 46 | 47 | it "should preserve relative order" do 48 | charsets = HTTP::Accept::Charsets.parse("utf-8, iso-8859-1;q=0.5, windows-1252;q=0.5") 49 | 50 | expect(charsets[0].charset).to be == "utf-8" 51 | expect(charsets[1].charset).to be == "iso-8859-1" 52 | expect(charsets[2].charset).to be == "windows-1252" 53 | end 54 | 55 | it "should accept empty string" do 56 | expect(HTTP::Accept::Charsets.parse("")).to be == [] 57 | end 58 | 59 | it "should not accept invalid input" do 60 | [ 61 | "utf-8;f=1", "us-ascii;utf-8", 62 | ";", "," 63 | ].each do |text| 64 | expect{HTTP::Accept::Charsets.parse(text)}.to raise_exception(HTTP::Accept::ParseError) 65 | end 66 | end 67 | 68 | it "should not accept nil input" do 69 | expect{HTTP::Accept::Charsets.parse(nil)}.to raise_exception(TypeError) 70 | end 71 | 72 | describe "browser_preferred_charsets" do 73 | it "should parse a non-blank header" do 74 | env = {HTTP::Accept::Charsets::HTTP_ACCEPT_CHARSET => "utf-8, iso-8859-1, sdch"} 75 | charsets = HTTP::Accept::Charsets.browser_preferred_charsets(env) 76 | expect(charsets.length).to be == 3 77 | expect(charsets[0].charset).to be == "utf-8" 78 | expect(charsets[1].charset).to be == "iso-8859-1" 79 | expect(charsets[2].charset).to be == "sdch" 80 | end 81 | 82 | it "should treat a blank header as an error" do 83 | env = {HTTP::Accept::Charsets::HTTP_ACCEPT_CHARSET => ""} 84 | expect{HTTP::Accept::Charsets.browser_preferred_charsets(env)}.to raise_exception(HTTP::Accept::ParseError) 85 | end 86 | 87 | it "should treat a missing header as '*'" do 88 | env = {} 89 | charsets = HTTP::Accept::Charsets.browser_preferred_charsets(env) 90 | expect(charsets.length).to be == 1 91 | expect(charsets[0].charset).to be == "*" 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/http/content_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | require 'http/accept/content_type' 7 | 8 | describe HTTP::Accept::ContentType do 9 | it "should raise argument error if constructed with wildcard" do 10 | expect{HTTP::Accept::ContentType.new("*", "*")}.to raise_exception(ArgumentError) 11 | end 12 | end 13 | 14 | describe HTTP::Accept::ContentType.new("text", "plain") do 15 | it "should format simple mime type" do 16 | expect(subject.to_s).to be == "text/plain" 17 | end 18 | 19 | it "can compare with string" do 20 | expect(subject).to be === "text/plain" 21 | end 22 | 23 | it "can compare with self" do 24 | expect(subject).to be === subject 25 | end 26 | end 27 | 28 | describe HTTP::Accept::ContentType.new("text", "plain", charset: 'utf-8') do 29 | it "should format simple mime type with options" do 30 | expect(subject.to_s).to be == "text/plain; charset=utf-8" 31 | end 32 | end 33 | 34 | describe HTTP::Accept::ContentType.new("text", "plain", charset: 'utf-8', q: 0.8) do 35 | it "should format simple mime type with multiple options" do 36 | expect(subject.to_s).to be == "text/plain; charset=utf-8; q=0.8" 37 | end 38 | end 39 | 40 | describe HTTP::Accept::ContentType.new("text", "plain", value: '["bar", "baz"]') do 41 | it "should format simple mime type with quoted options" do 42 | expect(subject.to_s).to be == "text/plain; value=\"[\\\"bar\\\", \\\"baz\\\"]\"" 43 | end 44 | 45 | it "should round trip to the same quoted string" do 46 | media_types = HTTP::Accept::MediaTypes.parse(subject.to_s) 47 | 48 | expect(media_types[0].mime_type).to be == "text/plain" 49 | expect(media_types[0].parameters).to be == {'value' => '["bar", "baz"]'} 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/http/encodings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016, by Matthew Kerwin. 5 | # Copyright, 2017-2024, by Samuel Williams. 6 | 7 | require 'http/accept/encodings' 8 | 9 | describe HTTP::Accept::Encodings::ContentCoding do 10 | it "should have default quality_factor of 1.0" do 11 | encoding = HTTP::Accept::Encodings::ContentCoding.new('gzip', nil) 12 | expect(encoding.quality_factor).to be == 1.0 13 | end 14 | end 15 | 16 | describe HTTP::Accept::Encodings do 17 | it "should parse basic header" do 18 | encodings = HTTP::Accept::Encodings.parse("gzip, deflate;q=0.5, identity;q=0.25") 19 | 20 | expect(encodings.length).to be == 3 21 | 22 | expect(encodings[0].encoding).to be == "gzip" 23 | expect(encodings[0].quality_factor).to be == 1.0 24 | 25 | expect(encodings[1].encoding).to be == "deflate" 26 | expect(encodings[1].quality_factor).to be == 0.5 27 | 28 | expect(encodings[2].encoding).to be == "identity" 29 | expect(encodings[2].quality_factor).to be == 0.25 30 | end 31 | 32 | it "should order based on quality factor" do 33 | encodings = HTTP::Accept::Encodings.parse("identity;q=0.25, deflate;q=0.5, gzip") 34 | expect(encodings.collect(&:encoding)).to be == %w{gzip deflate identity} 35 | 36 | encodings = HTTP::Accept::Encodings.parse("br,deflate;q=0.8,identity;q=0.6,gzip") 37 | expect(encodings.collect(&:encoding)).to be == %w{br gzip deflate identity} 38 | end 39 | 40 | it "should accept wildcard encoding" do 41 | encodings = HTTP::Accept::Encodings.parse("*;q=0") 42 | 43 | expect(encodings[0].encoding).to be == "*" 44 | expect(encodings[0].quality_factor).to be == 0 45 | end 46 | 47 | it "should preserve relative order" do 48 | encodings = HTTP::Accept::Encodings.parse("br, gzip;q=0.5, deflate;q=0.5") 49 | 50 | expect(encodings[0].encoding).to be == "br" 51 | expect(encodings[1].encoding).to be == "gzip" 52 | expect(encodings[2].encoding).to be == "deflate" 53 | end 54 | 55 | it "should accept empty string" do 56 | expect(HTTP::Accept::Encodings.parse("")).to be == [] 57 | end 58 | 59 | it "should not accept invalid input" do 60 | [ 61 | "gzip;f=1", "br;gzip", 62 | ";", "," 63 | ].each do |text| 64 | expect{HTTP::Accept::Encodings.parse(text)}.to raise_exception(HTTP::Accept::ParseError) 65 | end 66 | end 67 | 68 | it "should not accept nil input" do 69 | expect{HTTP::Accept::Encodings.parse(nil)}.to raise_exception(TypeError) 70 | end 71 | 72 | describe "browser_preferred_content_codings" do 73 | it "should parse a non-blank header" do 74 | env = {HTTP::Accept::Encodings::HTTP_ACCEPT_ENCODING => "gzip, deflate, sdch"} 75 | encodings = HTTP::Accept::Encodings.browser_preferred_content_codings(env) 76 | expect(encodings.length).to be == 3 77 | expect(encodings[0].encoding).to be == "gzip" 78 | expect(encodings[1].encoding).to be == "deflate" 79 | expect(encodings[2].encoding).to be == "sdch" 80 | end 81 | 82 | it "should treat a blank header as 'identity'" do 83 | env = {HTTP::Accept::Encodings::HTTP_ACCEPT_ENCODING => ""} 84 | encodings = HTTP::Accept::Encodings.browser_preferred_content_codings(env) 85 | expect(encodings.length).to be == 1 86 | expect(encodings[0].encoding).to be == "identity" 87 | end 88 | 89 | it "should treat a missing header as '*'" do 90 | env = {} 91 | encodings = HTTP::Accept::Encodings.browser_preferred_content_codings(env) 92 | expect(encodings.length).to be == 1 93 | expect(encodings[0].encoding).to be == "*" 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /test/http/frozen.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | require 'http/accept/media_types' 7 | require 'http/accept/languages' 8 | 9 | describe HTTP::Accept::MediaTypes::Map do 10 | let(:converter) do 11 | Struct.new(:content_type) do 12 | def split(*args) 13 | self.content_type.split(*args) 14 | end 15 | end 16 | end 17 | 18 | let(:text_html_converter) {converter.new("text/html")} 19 | let(:text_plain_converter) {converter.new("text/plain")} 20 | 21 | let(:map) {subject.new} 22 | 23 | it "should be possible to query frozen state" do 24 | map << text_html_converter 25 | map << text_plain_converter 26 | 27 | map.freeze 28 | 29 | media_types = HTTP::Accept::MediaTypes.parse("bob/dole, text/plain, text/*, */*") 30 | expect(map.for(media_types).first).to be == text_plain_converter 31 | end 32 | end 33 | 34 | describe HTTP::Accept::Languages::Locales do 35 | # Specified by the server, content localizations that are actually available: 36 | let(:locales) {HTTP::Accept::Languages::Locales.new(["en-us", "en-nz", "en-au"])} 37 | 38 | it "should be possible to query frozen state" do 39 | locales.freeze 40 | 41 | # Provided by the client: 42 | languages = HTTP::Accept::Languages.parse("ja, en-au, en") 43 | 44 | # The localized content which is best for this user: 45 | expect(locales & languages).to be == ["en-au", "en-us"] 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/http/languages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | # Copyright, 2021, by Khaled Hassan Hussein. 6 | 7 | require 'http/accept/languages' 8 | 9 | describe HTTP::Accept::Languages do 10 | it "should parse basic header" do 11 | languages = HTTP::Accept::Languages.parse("da, en-gb;q=0.5, en;q=0.25") 12 | 13 | expect(languages[0].locale).to be == "da" 14 | expect(languages[0].quality_factor).to be == 1.0 15 | 16 | expect(languages[1].locale).to be == "en-gb" 17 | expect(languages[1].quality_factor).to be == 0.5 18 | 19 | expect(languages[2].locale).to be == "en" 20 | expect(languages[2].quality_factor).to be == 0.25 21 | end 22 | 23 | it "should order based on quality factor" do 24 | languages = HTTP::Accept::Languages.parse("en-gb;q=0.25, en;q=0.5, en-us") 25 | expect(languages.collect(&:locale)).to be == %w{en-us en en-gb} 26 | 27 | languages = HTTP::Accept::Languages.parse("en-us,en-gb;q=0.8,en;q=0.6,es-419") 28 | expect(languages.collect(&:locale)).to be == %w{en-us es-419 en-gb en} 29 | end 30 | 31 | it "should accept wildcard language" do 32 | languages = HTTP::Accept::Languages.parse("*;q=0") 33 | 34 | expect(languages[0].locale).to be == "*" 35 | expect(languages[0].quality_factor).to be == 0 36 | end 37 | 38 | it "should preserve relative order" do 39 | languages = HTTP::Accept::Languages.parse("en, de;q=0.5, jp;q=0.5") 40 | 41 | expect(languages[0].locale).to be == "en" 42 | expect(languages[1].locale).to be == "de" 43 | expect(languages[2].locale).to be == "jp" 44 | end 45 | 46 | it "should parse with optional whitespace" do 47 | languages = HTTP::Accept::Languages.parse("de, en-US; q=0.7, en ; q=0.3") 48 | 49 | expect(languages[0].locale).to be == "de" 50 | expect(languages[1].locale).to be == "en-US" 51 | expect(languages[2].locale).to be == "en" 52 | end 53 | 54 | it "should accept quality factors up to 6 decimal places" do 55 | languages = HTTP::Accept::Languages.parse("en;q=0.123456") 56 | 57 | expect(languages[0].locale).to be == "en" 58 | expect(languages[0].quality_factor).to be == 0.123456 59 | end 60 | 61 | it "should accept empty strings" do 62 | expect(HTTP::Accept::Languages.parse("")).to be == [] 63 | end 64 | 65 | it "should not accept quality factors with more than 6 decimal places" do 66 | text = "en;q=0.1234567" 67 | 68 | expect{HTTP::Accept::Languages.parse(text)}.to raise_exception(HTTP::Accept::ParseError) 69 | end 70 | 71 | it "should not accept invalid input" do 72 | [ 73 | "en;f=1", "de;jp", 74 | ";", "," 75 | ].each do |text| 76 | expect{HTTP::Accept::Languages.parse(text)}.to raise_exception(HTTP::Accept::ParseError) 77 | end 78 | end 79 | 80 | it "should not accept nil" do 81 | expect{HTTP::Accept::Languages.parse(nil)}.to raise_exception(TypeError) 82 | end 83 | end 84 | 85 | describe HTTP::Accept::Languages::Locales do 86 | # Specified by the server, content localizations that are actually available: 87 | let(:locales) {HTTP::Accept::Languages::Locales.new(["en-us", "en-nz", "en-au"])} 88 | 89 | it "should filter and expand the requested locales" do 90 | # Provided by the client: 91 | languages = HTTP::Accept::Languages.parse("en-au, en") 92 | 93 | # The localized content which is best for this user: 94 | expect(locales & languages).to be == ["en-au", "en-us"] 95 | end 96 | 97 | it "it should filter the requested locale" do 98 | languages = HTTP::Accept::Languages.parse("en-au") 99 | expect(locales & languages).to be == ["en-au"] 100 | end 101 | 102 | it "it should expand the requested locale" do 103 | languages = HTTP::Accept::Languages.parse("en") 104 | expect(locales & languages).to be == ["en-us"] 105 | end 106 | 107 | it "should include all generic locales" do 108 | expect(locales).to be(:include?, "en-us") 109 | expect(locales).to be(:include?, "en-nz") 110 | expect(locales).to be(:include?, "en-au") 111 | expect(locales).to be(:include?, "en") 112 | end 113 | 114 | it "can be joined into a string" do 115 | expect(locales.join(',')).to be == "en-us,en-nz,en-au" 116 | end 117 | 118 | it "can be added together" do 119 | others = ['ja'] 120 | all_locales = locales + others 121 | 122 | expect(all_locales).to be(:include?, "en-us") 123 | expect(all_locales).to be(:include?, "en-nz") 124 | expect(all_locales).to be(:include?, "en-au") 125 | expect(all_locales).to be(:include?, "en") 126 | expect(all_locales).to be(:include?, "ja") 127 | end 128 | 129 | it "can be converted to an array of names" do 130 | expect(locales.to_a).to be == locales.names 131 | end 132 | 133 | it "can be enumerated using each" do 134 | expect(locales.each.to_a).to be == locales.names 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /test/http/media_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | require 'http/accept/media_types' 7 | require 'http/accept/content_type' 8 | 9 | describe HTTP::Accept::MediaTypes do 10 | it "should parse basic header with multiple parameters" do 11 | media_types = HTTP::Accept::MediaTypes.parse("text/html;q=0.5, application/json") 12 | 13 | expect(media_types[0].mime_type).to be == "application/json" 14 | expect(media_types[0].parameters).to be == {} 15 | expect(media_types[1].mime_type).to be == "text/html" 16 | expect(media_types[1].parameters).to be == {'q' => '0.5'} 17 | end 18 | 19 | it "should parse basic header with multiple parameters" do 20 | media_types = HTTP::Accept::MediaTypes.parse("text/html;q=0.5, application/json;q=1.0; version=1") 21 | 22 | expect(media_types[0].mime_type).to be == "application/json" 23 | expect(media_types[0].parameters).to be == {'q' => '1.0', 'version' => '1'} 24 | expect(media_types[1].mime_type).to be == "text/html" 25 | expect(media_types[1].parameters).to be == {'q' => '0.5'} 26 | end 27 | 28 | it "should parse quoted strings correctly" do 29 | # Many parsers use something like `header_value.split(',')` and you know from that point it's downhill. 30 | media_types = HTTP::Accept::MediaTypes.parse("foo/bar;key=\"A,B,C\"") 31 | 32 | expect(media_types.size).to be == 1 33 | expect(media_types[0].mime_type).to be == "foo/bar" 34 | expect(media_types[0].parameters).to be == {'key' => "A,B,C"} 35 | end 36 | 37 | it "should accept empty string" do 38 | expect(HTTP::Accept::MediaTypes.parse("")).to be == [] 39 | end 40 | 41 | it "should not accept invalid input" do 42 | [ 43 | "foo", 44 | "foo/", 45 | "foo/bar;", 46 | "foo/bar;x", 47 | "foo/bar;x=", 48 | "foo/bar;x=\"", 49 | "foo/bar;x=\"baz", 50 | "foo/bar;x=", 51 | ";foo/bar", 52 | ",", 53 | ].each do |text| 54 | expect{HTTP::Accept::MediaTypes.parse(text)}.to raise_exception(HTTP::Accept::ParseError) 55 | end 56 | end 57 | 58 | it "should not accept nil input" do 59 | expect{HTTP::Accept::MediaTypes.parse(nil)}.to raise_exception(TypeError) 60 | end 61 | end 62 | 63 | AMediaTypeSelector = Sus::Shared("a media type selector") do |header_value, priorities = nil| 64 | let(:media_types) {HTTP::Accept::MediaTypes.parse(header_value)} 65 | 66 | it "should parse without error" do 67 | expect{media_types}.not.to raise_exception 68 | end 69 | 70 | it "should have at least one entry" do 71 | expect(media_types.size).to be > 0 72 | end 73 | 74 | it "should have the correct priorities" do 75 | expect(media_types).to be == priorities 76 | end if priorities 77 | end 78 | 79 | # Copied from https://greenbytes.de/tech/webdav/rfc7231.html#rfc.section.5.3.2 80 | describe "RFC Example Accept: headers" do 81 | it_behaves_like AMediaTypeSelector, "audio/*; q=0.2, audio/basic" 82 | it_behaves_like AMediaTypeSelector, "text/plain; q=0.5, text/html,\n text/x-dvi; q=0.8, text/x-c" 83 | it_behaves_like AMediaTypeSelector, "text/*, text/plain, text/plain;format=flowed, */*" 84 | 85 | it_behaves_like AMediaTypeSelector, "text/*;q=0.3, text/html;q=0.7, text/html;level=1,\n text/html;level=2;q=0.4, */*;q=0.5", [ 86 | HTTP::Accept::MediaTypes::MediaRange.new("text", "html", "level" => "1"), 87 | HTTP::Accept::MediaTypes::MediaRange.new("text", "html", "q" => "0.7"), 88 | HTTP::Accept::MediaTypes::MediaRange.new("*", "*", "q" => "0.5"), 89 | HTTP::Accept::MediaTypes::MediaRange.new("text", "html", "level" => "2", "q" => "0.4"), 90 | HTTP::Accept::MediaTypes::MediaRange.new("text", "*", "q" => "0.3"), 91 | ] 92 | end 93 | 94 | AWildcardMediaRange = Sus::Shared("a wildcard media range") do |env| 95 | let(:wildcard_media_ranges) {[HTTP::Accept::MediaTypes::WILDCARD_MEDIA_RANGE]} 96 | 97 | it "should match any content type" do 98 | expect(HTTP::Accept::MediaTypes.browser_preferred_media_types(env)).to be == wildcard_media_ranges 99 | end 100 | end 101 | 102 | describe HTTP::Accept::MediaTypes do 103 | it_behaves_like AWildcardMediaRange, {'HTTP_ACCEPT' => ' */* '} 104 | it_behaves_like AWildcardMediaRange, {'HTTP_ACCEPT' => '*/*'} 105 | 106 | # http://stackoverflow.com/questions/12130910/how-to-interpret-empty-http-accept-header 107 | it_behaves_like AWildcardMediaRange, {'HTTP_ACCEPT' => ' '} 108 | it_behaves_like AWildcardMediaRange, {'HTTP_ACCEPT' => ''} 109 | 110 | let(:text_plain_media_range) {HTTP::Accept::MediaTypes::MediaRange.new("text", "plain", {})} 111 | 112 | it "should parse accept header" do 113 | media_types = HTTP::Accept::MediaTypes.browser_preferred_media_types('HTTP_ACCEPT' => text_plain_media_range.to_s) 114 | 115 | expect(media_types[0]).to be === text_plain_media_range 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /test/http/media_types/map.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | require 'http/accept/media_types' 7 | require 'http/accept/media_types/map' 8 | require 'http/accept/content_type' 9 | 10 | describe HTTP::Accept::MediaTypes::Map do 11 | let(:converter) do 12 | Struct.new(:content_type) do 13 | def split(*args) 14 | self.content_type.split(*args) 15 | end 16 | end 17 | end 18 | 19 | let(:text_html_converter) {converter.new("text/html")} 20 | 21 | let(:text_plain_content_type) {HTTP::Accept::ContentType.new("text", "plain", charset: 'utf-8')} 22 | let(:text_plain_converter) {converter.new(text_plain_content_type)} 23 | 24 | let(:map) {subject.new} 25 | 26 | it "should give the correct converter when specified completely" do 27 | map << text_html_converter 28 | map << text_plain_converter 29 | 30 | media_types = HTTP::Accept::MediaTypes.parse("text/plain, text/*, */*") 31 | expect(map.for(media_types).first).to be == text_plain_converter 32 | 33 | media_types = HTTP::Accept::MediaTypes.parse("text/html, text/*, */*") 34 | expect(map.for(media_types).first).to be == text_html_converter 35 | end 36 | 37 | it "should match the wildcard subtype converter" do 38 | map << text_html_converter 39 | map << text_plain_converter 40 | 41 | media_types = HTTP::Accept::MediaTypes.parse("text/*, */*") 42 | expect(map.for(media_types).first).to be == text_html_converter 43 | 44 | media_types = HTTP::Accept::MediaTypes.parse("*/*") 45 | expect(map.for(media_types).first).to be == text_html_converter 46 | end 47 | 48 | it "should fail to match if no media types match" do 49 | map << text_plain_converter 50 | 51 | expect(map.for(["application/json"])).to be_nil 52 | end 53 | 54 | it "should fail to match if no media types specified" do 55 | expect(map.for(["text/*", "*/*"])).to be_nil 56 | end 57 | 58 | it "should freeze converters" do 59 | map << text_html_converter 60 | 61 | map.freeze 62 | 63 | expect(text_html_converter).to be(:frozen?) 64 | end 65 | 66 | it "should assign and retrive media ranges" do 67 | map["*/*"] = :test 68 | 69 | expect(map["*/*"]).to be == :test 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/http/negotiation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | require 'http/accept/media_types' 7 | require 'http/accept/content_type' 8 | 9 | describe HTTP::Accept::MediaTypes do 10 | let(:json_content_type) {HTTP::Accept::ContentType.new("application", "json")} 11 | let(:html_content_type) {HTTP::Accept::ContentType.new("text", "html")} 12 | let(:wildcard_media_range) {HTTP::Accept::MediaTypes::MediaRange.new("*", "*")} 13 | 14 | let(:map) {HTTP::Accept::MediaTypes::Map.new} 15 | 16 | # Sometimes it is necessary to handle very unusual configurations of Accept headers. This represents a specific case where requests which prioritise text/html or only match the wildcard should be handled independently of the case where application/json is specified. Because of how the map is configured, it is possible to specifically handle all three cases as required. 17 | it "should render json only if explicitly requested" do 18 | # Adding the wildcard first means that only '*/*' is specified, and won't be set by the json_content_type. 19 | map << wildcard_media_range << json_content_type << html_content_type 20 | 21 | expect(map.for(["*/*"])).to be == [wildcard_media_range, "*/*"] 22 | expect(map.for(["application/json"])).to be == [json_content_type, "application/json"] 23 | expect(map.for(["text/html"])).to be == [html_content_type, "text/html"] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/http/quoted_string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Released under the MIT License. 4 | # Copyright, 2016-2024, by Samuel Williams. 5 | 6 | require 'http/accept/quoted_string' 7 | 8 | describe HTTP::Accept::QuotedString do 9 | it "should ignore linear whitespace" do 10 | quoted_string = HTTP::Accept::QuotedString.unquote(%Q{"Hello\r\n World"}) 11 | 12 | expect(quoted_string).to be == "Hello World" 13 | end 14 | end 15 | --------------------------------------------------------------------------------