accept_headers 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4fe78baeb6173880a3a828984a2ece4ba13c586d56ec7aa2c9dcec8e1706cf08
4
- data.tar.gz: ab2d2265cef9efb0615a756940c58723c754f44f446b16a39cdbbb5b954acc20
3
+ metadata.gz: f3e0dda15c77d1bf57df59c16d27f829e3fadf8868f63717e59bd65247b0ec3f
4
+ data.tar.gz: 0baaeba925e9b4582f43fd3f699ae03debd44a8bb7d31b4b4501f178984ccb2e
5
5
  SHA512:
6
- metadata.gz: 25ec2d46da27efaa2b46ba60a48dbdda47fa506930ae6ebbc609282c4494ef46ec575c447ca0d88432d9310f638919cfc1afc72ead05e12691342ecdb8a342d8
7
- data.tar.gz: 1726ae60e312b8cae49d41713bd23391912cec46a327a2e2dd3bed8107db62f8bf3feab28f66a9bf2bb5d443ce44d201f7e0177d8b69f3e2ede989db8c176b16
6
+ metadata.gz: b76e1a583842a4952c99b953fa406edc682bd4f5e30eb07f383f07f42cb89b71f4d7b6e31f4010ad1f375406b82ac65b2462b2672529a689e18fd31ed562c9df
7
+ data.tar.gz: c4920549cbed068d58abec12c24058ea3d8cc8b6c4d6768374f698d7e4f544f4c03c4d831e67b83228b237e0136d384dc9877b9b4c772fa3ce1a48c9419299e1
@@ -0,0 +1,8 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: bundler
4
+ directory: "/"
5
+ schedule:
6
+ interval: daily
7
+ time: "10:00"
8
+ open-pull-requests-limit: 10
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on: [push, pull_request]
4
+
5
+ permissions:
6
+ contents: read
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ ruby: ['3.3', '3.4', '4.0']
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+ with:
18
+ persist-credentials: false
19
+ - uses: ruby/setup-ruby@v1
20
+ with:
21
+ ruby-version: ${{ matrix.ruby }}
22
+ bundler-cache: true
23
+ - run: bundle exec rake test
data/CHANGELOG.md CHANGED
@@ -1,4 +1,16 @@
1
- ## HEAD
1
+ ## 0.2.0 / Unreleased
2
+
3
+ * Replace Travis CI with GitHub Actions covering maintained Ruby 3.3, 3.4, and 4.0, and repair fresh test dependency setup.
4
+ * Follow RFC 9110 negotiation rules and RFC 4647 Basic Filtering for languages.
5
+ * Honor specific quality values and exclusions before wildcard preferences.
6
+ * Match declared media parameters and preserve supported representation parameters instead of copying requested parameters.
7
+ * Preserve quoted delimiters and escapes, validate media tokens, and serialize media parameters before quality values.
8
+ * Handle raw HTTP field bytes without encoding exceptions and preserve quoted non-ASCII values.
9
+ * Parse current quality syntax, including `0.`, `1.`, and case-insensitive `q`, while retaining the documented invalid-quality recovery value.
10
+ * Distinguish missing and empty fields and apply encoding identity fallback and exclusion rules.
11
+ * Support multi-subtag languages and case-insensitive matching. Replace `en-*` header ranges with `en`; bare supported tags remain bare.
12
+ * Compare values by all their fields instead of equating unrelated values with the same sort priority.
13
+ * Update usage, upgrade notes, and Rack 3 examples. These corrections change results for callers relying on the previous behavior; see [Upgrading from 0.1.0 to 0.2.0](README.md#upgrading-from-010-to-020) before upgrading.
2
14
 
3
15
  ## 0.1.0 / April 19, 2019
4
16
 
data/Gemfile CHANGED
@@ -4,6 +4,7 @@ source "https://rubygems.org"
4
4
  gemspec
5
5
 
6
6
  group :test do
7
+ gem "csv"
7
8
  gem "minitest-focus"
8
9
  gem "simplecov", require: false
9
10
  end
@@ -11,5 +12,5 @@ end
11
12
  group :development, :test do
12
13
  gem "pry"
13
14
  gem "awesome_print"
14
- gem "rack-test"
15
+ gem "rack"
15
16
  end
data/README.md CHANGED
@@ -1,220 +1,180 @@
1
- [![Build Status](https://travis-ci.org/kamui/accept_headers.png)](https://travis-ci.org/kamui/accept_headers)
1
+ [![CI](https://github.com/kamui/accept_headers/actions/workflows/ci.yml/badge.svg)](https://github.com/kamui/accept_headers/actions/workflows/ci.yml)
2
2
 
3
3
  # AcceptHeaders
4
4
 
5
- **AcceptHeaders** is a ruby library that does content negotiation and parses and sorts http accept headers.
5
+ AcceptHeaders parses HTTP `Accept`, `Accept-Encoding`, and `Accept-Language` headers and negotiates a match against the representations your application supports. It has no runtime dependencies.
6
6
 
7
- Some features of the library are:
7
+ This README describes version 0.2.0. See [Upgrading from 0.1.0 to 0.2.0](#upgrading-from-010-to-020) before updating an existing installation.
8
8
 
9
- * Strict adherence to [RFC 2616][rfc], specifically [section 14][rfc-sec14]
10
- * Full support for the [Accept][rfc-sec14-1], [Accept-Encoding][rfc-sec14-3],
11
- and [Accept-Language][rfc-sec14-4] HTTP request headers
12
- * `Accept-Charset` is not supported because it's [obsolete](https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation#The_Accept-Charset.3A_header)
13
- * Parser tested against all IANA registered [media types][iana-media-types] and [encodings][iana-encodings]
14
- * A comprehensive [spec suite][spec] that covers many edge cases
15
-
16
- 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.
17
-
18
- [rfc]: http://www.w3.org/Protocols/rfc2616/rfc2616.html
19
- [rfc-sec14]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
20
- [rfc-sec14-1]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1
21
- [rfc-sec14-3]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.3
22
- [rfc-sec14-4]: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.4
23
- [iana-media-types]: https://www.iana.org/assignments/media-types/media-types.xhtml
24
- [iana-encodings]: https://www.iana.org/assignments/http-parameters/http-parameters.xml#content-coding
25
- [spec]: http://github.com/kamui/accept_headers/tree/master/spec/
9
+ Negotiation follows [RFC 9110, sections 12.4 and 12.5](https://www.rfc-editor.org/rfc/rfc9110.html#section-12.4). Language matching uses [RFC 4647 Basic Filtering](https://www.rfc-editor.org/rfc/rfc4647.html#section-3.3.1). `Accept-Charset` is not supported because it is deprecated.
26
10
 
27
11
  ## Installation
28
12
 
29
- Add this line to your application's Gemfile:
13
+ CI covers the maintained Ruby series: 3.3, 3.4, and 4.0. Older Ruby versions remain allowed by the gem specification but are not tested.
14
+
15
+ Add the gem to your Gemfile and run `bundle install`:
30
16
 
31
17
  ```ruby
32
- gem 'accept_headers'
18
+ gem 'accept_headers', '~> 0.2.0'
33
19
  ```
34
20
 
35
- And then execute:
36
-
37
- $ bundle
38
-
39
- Or install it yourself as:
21
+ ## Usage
40
22
 
41
- $ gem install accept_headers
23
+ ```ruby
24
+ require 'accept_headers'
25
+ ```
42
26
 
43
- ## Usage
27
+ Each negotiator accepts a header value, with or without its conventional header name prefix. `#list` returns parsed entries sorted by preference. `#negotiate` accepts one supported string or an array and returns a `MediaType`, `Encoding`, or `Language`, or `nil` when none is acceptable. `#accept?` checks whether negotiation succeeds.
44
28
 
45
- ### Accept
29
+ Entries in `#list` describe client preferences. Their order alone does not determine the best supported representation. Negotiation accounts for the most specific matching range before comparing quality values.
46
30
 
47
- `AcceptHeaders::MediaType::Negotiator` is a class that is initialized with an `Accept` header string and will internally store an array of `MediaType`s in descending order according to the spec, which takes into account `q` value, `type`/`subtype` and `extensions` specificity.
31
+ ### Media types
48
32
 
49
33
  ```ruby
50
- 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'
51
- media_types = AcceptHeaders::MediaType::Negotiator.new(accept_header)
34
+ media_types = AcceptHeaders::MediaType::Negotiator.new(
35
+ 'text/html;q=0.1, text/*;q=0.9, application/json;q=0.5'
36
+ )
52
37
 
53
- media_types.list
38
+ media_types.negotiate(['text/html', 'application/json']).media_range
39
+ # => "application/json"
54
40
 
55
- # Returns:
41
+ media_types.accept?('text/html')
42
+ # => true
56
43
 
57
- [
58
- AcceptHeaders::MediaType.new('text', 'html', extensions: { 'level' => '1' }),
59
- AcceptHeaders::MediaType.new('text', 'html', q: 0.7),
60
- AcceptHeaders::MediaType.new('*', '*', q: 0.5),
61
- AcceptHeaders::MediaType.new('text', 'html', q: 0.4, extensions: { 'level' => '2' }),
62
- AcceptHeaders::MediaType.new('text', '*', q: 0.3)
63
- ]
44
+ AcceptHeaders::MediaType::Negotiator.new('text/html;q=0, */*;q=1').accept?('text/html')
45
+ # => false
64
46
  ```
65
47
 
66
- `#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.
48
+ The exact `text/html` range sets HTML's quality to `0.1`, even though `text/*` has a higher quality. A more specific positive range can also override a wildcard exclusion.
67
49
 
68
- 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`.
50
+ Media parameters constrain the match. Declare each representation that your application actually supports:
69
51
 
70
52
  ```ruby
71
- # The same media_types variable as above
72
- media_types.negotiate(['text/html', 'text/plain'])
73
-
74
- # Returns this equivalent:
75
-
76
- AcceptHeader::MediaType.new('text', 'html', extensions: { 'level' => '1' })
53
+ versions = AcceptHeaders::MediaType::Negotiator.new(
54
+ 'application/json;version=2, application/json;version=1;q=0.8'
55
+ )
56
+
57
+ match = versions.negotiate(['application/json;version=1', 'application/json;version=2'])
58
+ match.media_range
59
+ # => "application/json"
60
+ match.extensions
61
+ # => { "version" => "2" }
62
+
63
+ versions.negotiate('application/json')
64
+ # => nil
77
65
  ```
78
66
 
79
- 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.
67
+ `#extensions` retains its historical name and contains media parameters. Under RFC 9110, parameters after `q` are also media parameters. Negotiation preserves the selected supported representation's parameters. It does not copy unsupported parameters from the request.
80
68
 
81
- ```ruby
82
- versions_header = 'Accept: application/json;version=2,application/json;version=1;q=0.8'
83
- media_types = AcceptHeaders::MediaType::Negotiator.new(versions_header)
69
+ Media type names and parameter names match without regard to case. Parameter values are case-sensitive except for `charset` values. Double-quoted values support commas, semicolons, and backslash escapes. `#to_s` quotes parameter values when needed and writes `q` last.
84
70
 
85
- m = media_types.negotiate('application/json')
86
- puts m.extensions['version'] # returns '2'
87
- ```
88
-
89
- `#accept?`:
71
+ ### Encodings
90
72
 
91
73
  ```ruby
92
- media_types.accept?('text/html') # true
74
+ encodings = AcceptHeaders::Encoding::Negotiator.new('br, gzip;q=0.8')
75
+ encodings.negotiate(['gzip', 'identity']).encoding
76
+ # => "gzip"
77
+ encodings.accept?('identity')
78
+ # => true
79
+
80
+ AcceptHeaders::Encoding::Negotiator.new('').accept?('gzip')
81
+ # => false
82
+ AcceptHeaders::Encoding::Negotiator.new('').accept?('identity')
83
+ # => true
84
+ AcceptHeaders::Encoding::Negotiator.new(nil).accept?('gzip')
85
+ # => true
93
86
  ```
94
87
 
95
- ### Accept-Encoding
88
+ A missing encoding header allows any coding. A present empty header allows only an unencoded response, represented by `identity`. For a nonempty header, identity is a fallback unless `identity;q=0` or `*;q=0` excludes it. Explicit coding preferences take precedence over `*`. Coding names match without regard to case.
96
89
 
97
- `AcceptHeader::Encoding::Encoding`:
90
+ Selecting `gzip` does not compress the response body. Your application or compression middleware must produce the selected encoding. Omit `Content-Encoding` for identity responses.
98
91
 
99
- ```ruby
100
- accept_encoding = 'Accept-Encoding: deflate; q=0.5, gzip, compress; q=0.8, identity'
101
- encodings = AcceptHeaders::Encoding::Negotiator.new(accept_encoding)
92
+ ### Languages
102
93
 
103
- encodings.list
104
-
105
- # Returns:
106
-
107
- [
108
- AcceptHeaders::Encoding.new('gzip'),
109
- AcceptHeaders::Encoding.new('compress', q: 0.8),
110
- AcceptHeaders::Encoding.new('deflate', q: 0.5)
111
- ]
94
+ ```ruby
95
+ languages = AcceptHeaders::Language::Negotiator.new('zh-Hant-TW, en;q=0.8')
96
+ languages.negotiate(['en-US', 'zh-Hant-TW']).language_tag
97
+ # => "zh-hant-tw"
98
+ languages.accept?('en-GB')
99
+ # => true
100
+ languages.accept?('eng')
101
+ # => false
102
+
103
+ AcceptHeaders::Language::Negotiator.new('en').negotiate('en').language_tag
104
+ # => "en"
112
105
  ```
113
106
 
114
- `#negotiate`:
107
+ Basic Filtering matches a complete range or a prefix followed by a hyphen, without regard to case. Ranges can contain multiple subtags. `*` matches otherwise unspecified languages. More specific ranges determine quality and exclusions.
115
108
 
116
- ```ruby
117
- encodings.negotiate(['gzip', 'compress'])
109
+ Use `en` to match English variants. Extended wildcard ranges such as `en-*` are outside the HTTP basic language-range syntax and are skipped. This is filtering, so `en-GB` does not fall back to `en` or match `en-US`.
118
110
 
119
- # Returns this equivalent:
111
+ `#primary_tag` returns the first subtag. `#subtag` returns the remaining subtags as one lowercase string. For a bare tag, `#subtag` retains the historical `*` sentinel. Language tags returned by negotiation are lowercase supported tags. Bare tags remain bare, so `en` is not emitted as `en-*`.
120
112
 
121
- AcceptHeader::Encoding.new('gzip')
122
- ```
113
+ ### Missing, empty, and malformed headers
123
114
 
124
- `#accept?`:
115
+ A `nil` header represents an absent field and allows any supported value. A present empty `Accept` or `Accept-Language` contains no acceptable entries. Empty `Accept-Encoding` allows only identity.
125
116
 
126
- ```ruby
127
- encodings.accept?('gzip') # true
117
+ The parsers skip malformed entries. An unterminated quoted string rejects the whole list because its comma boundaries are ambiguous. Apostrophes are token characters, not string delimiters. Headers with an invalid Ruby string encoding are read as raw HTTP bytes. Quoted non-ASCII values are preserved.
128
118
 
129
- # Identity is accepted as long as it's not explicitly rejected 'identity;q=0'
119
+ Invalid quality values recover to `0.001`, preserving this library's lenient parsing policy. Missing quality defaults to `1`. Valid quality syntax follows RFC 9110: values range from `0` to `1` with at most three decimal digits, including `0.`, `1.`, and `1.000`. The parameter name `q` is case-insensitive.
130
120
 
131
- encodings.accept?('identity') # true
132
- ```
121
+ `#to_s` produces a normalized preference header, including quality values. Use `#media_range`, `#encoding`, or `#language_tag` when you need the selected value alone. With `==`, objects compare equal only when their class, type-specific fields, and quality values agree. Sorting order does not imply equality.
133
122
 
134
- ### Accept-Language
123
+ ## Rack middleware
135
124
 
136
- `Accept::Language::Negotiator`:
125
+ The middleware adds negotiators to the request environment. The application selects representations and creates the response:
137
126
 
138
127
  ```ruby
139
- accept_language = 'Accept-Language: en-*, en-us, *;q=0.8'
140
- languages = AcceptHeaders::Language::Negotiator.new(accept_language)
141
-
142
- languages.list
143
-
144
- # Returns:
128
+ require 'accept_headers/middleware'
145
129
 
146
- [
147
- AcceptHeaders::Language.new('en', 'us'),
148
- AcceptHeaders::Language.new('en', '*'),
149
- AcceptHeaders::Language.new('*', '*', q: 0.8)
150
- ]
130
+ use AcceptHeaders::Middleware
131
+ run lambda { |env|
132
+ media = env['accept_headers.media_types'].negotiate('application/json')
133
+ encoding = env['accept_headers.encodings'].negotiate('identity')
134
+ language = env['accept_headers.languages'].negotiate(['en-US', 'en-GB'])
135
+ vary = 'Accept, Accept-Encoding, Accept-Language'
136
+
137
+ if media && encoding && language
138
+ headers = {
139
+ 'content-type' => media.media_range,
140
+ 'content-language' => language.language_tag,
141
+ 'vary' => vary
142
+ }
143
+ [200, headers, ['{"message":"Hello"}']]
144
+ else
145
+ [406, { 'content-type' => 'text/plain', 'vary' => vary }, ['Not acceptable']]
146
+ end
147
+ }
151
148
  ```
152
149
 
153
- `#negotiate`:
154
-
155
- ```ruby
156
- languages.negotiate(['en-us', 'zh-Hant'])
150
+ This example supports an unencoded JSON body in two English locales. It chooses to honor negotiation failures with `406`. Applications can choose a different fallback policy. When request headers influence response selection, set `Vary` so caches distinguish the variants. Rack 3 response header names must be lowercase.
157
151
 
158
- # Returns this equivalent:
152
+ ## Upgrading from 0.1.0 to 0.2.0
159
153
 
160
- AcceptHeaders::Language.new('en', 'us')
161
- ```
154
+ Version 0.2.0 corrects behavior from version 0.1.0:
162
155
 
163
- `#accept?`:
156
+ - Explicit exclusions and specific media preferences now take precedence over broader wildcard matches.
157
+ - Parameterized media requests require matching supported parameters. Replace `negotiate('application/json')` with the versioned representations your application actually supports.
158
+ - Media parameters follow RFC 9110, including parameters after `q`. Serialization places `q` last and quotes values when needed.
159
+ - Missing and empty headers have distinct meanings. Identity fallback follows the encoding rules above.
160
+ - Replace `en-*` header ranges with `en`. Multi-subtag language ranges now work, and bare supported tags remain bare.
161
+ - Equality compares actual values, including parameters. Two different values with equal sort priority are no longer equal.
164
162
 
165
- ```ruby
166
- languages.accept?('en-gb') # true
167
- ```
163
+ The public negotiator classes, constructors, and `#extensions` accessor remain available. These corrections can change selection results for callers that depended on the previous behavior.
168
164
 
169
- ## Rack Middleware
165
+ ## Development
170
166
 
171
- Add the middleware:
167
+ Install dependencies with `bundle install`, then run the complete suite:
172
168
 
173
- ```ruby
174
- require 'accept_headers/middleware'
175
- use AcceptHeaders::Middleware
176
- run YourApp
169
+ ```sh
170
+ bundle exec rake test
177
171
  ```
178
172
 
179
- 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.
180
-
181
- ```ruby
182
- class YourApp
183
- def initialize(app)
184
- @app = app
185
- end
186
-
187
- def call(env)
188
- # List your arrays of supported media types, encodings, languages. This can be global or per route/controller
189
- supported_media_types = %w[application/json application/xml text/html text/plain]
190
- supported_encodings = %w[gzip identify]
191
- supported_languages = %w[en-US en-GB]
192
-
193
- # Call the Negotiators and pass in the supported arrays and it'll return the best match
194
- matched_media_type = env["accept_headers.media_types"].negotiate(supported_media_types)
195
- matched_encoding = env["accept_headers.encodings"].negotiate(supported_encodings)
196
- matched_language = env["accept_headers.languages"].negotiate(supported_languages)
197
-
198
- # Set a default, in this case an empty string, in case of a bad header that cannot be parsed
199
- # The return value is a MediaType, Encoding, or Language depending on the case:
200
- # On MediaType, you can call #type ('text'), #subtype ('html'), #media_range ('text/html') to get the stringified parts
201
- # On Encoding, you can call #encoding to get the string encoding ('gzip')
202
- # On Language, you can call #primary_tag ('en'), #subtag ('us'), or #language_tag ('en-us')
203
- headers = {
204
- 'Content-Type' => matched_media_type ? matched_media_type.media_range : '',
205
- 'Content-Encoding' => matched_encoding ? matched_encoding.encoding : '',
206
- 'Content-Language' => matched_language ? matched_language.language_tag : '',
207
- }
208
-
209
- [200, headers, ["Hello World!"]]
210
- end
211
- end
212
- ```
173
+ The suite includes negotiation regressions and parser checks against the bundled IANA media-type and coding snapshots. Those snapshots are historical fixtures, not a live registry mirror. The CI workflow runs the suite on Ruby 3.3, 3.4, and 4.0.
213
174
 
214
175
  ## Contributing
215
176
 
216
- 1. Fork it ( https://github.com/[my-github-username]/accept_headers/fork )
217
- 2. Create your feature branch (`git checkout -b my-new-feature`)
218
- 3. Commit your changes (`git commit -am 'Add some feature'`)
219
- 4. Push to the branch (`git push origin my-new-feature`)
220
- 5. Create a new Pull Request
177
+ 1. Fork [kamui/accept_headers](https://github.com/kamui/accept_headers).
178
+ 2. Create a branch for your change.
179
+ 3. Add regression tests and run the suite.
180
+ 4. Open a pull request.
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
9
9
  spec.authors = ["Jack Chu"]
10
10
  spec.email = ["kamuigt@gmail.com"]
11
11
  spec.summary = %q{A ruby library that does content negotiation and parses and sorts http accept headers.}
12
- spec.description = %q{A ruby library that does content negotiation and parses and sorts http accept headers. Adheres to RFC 2616.}
12
+ spec.description = %q{A ruby library that parses HTTP accept headers and negotiates media types, content codings, and languages using RFC 9110 and RFC 4647 Basic Filtering.}
13
13
  spec.homepage = ""
14
14
  spec.license = "MIT"
15
15
 
@@ -20,7 +20,7 @@ Gem::Specification.new do |spec|
20
20
 
21
21
  spec.required_ruby_version = '>= 2.4.0'
22
22
 
23
- spec.add_development_dependency "rake", "~> 12.0"
23
+ spec.add_development_dependency "rake", "~> 13.0"
24
24
 
25
25
  spec.add_development_dependency "minitest", "~> 5.11"
26
26
  spec.add_development_dependency "guard"
@@ -5,6 +5,10 @@ module AcceptHeaders
5
5
 
6
6
  attr_reader :q
7
7
 
8
+ def ==(other)
9
+ other.instance_of?(self.class) && to_h == other.to_h
10
+ end
11
+
8
12
  def reject?(other)
9
13
  if q != 0.0
10
14
  false
@@ -39,5 +43,9 @@ module AcceptHeaders
39
43
  def match(other)
40
44
  raise NotImplementedError.new("#match is not implemented")
41
45
  end
46
+
47
+ def specificity
48
+ raise NotImplementedError.new("#specificity is not implemented")
49
+ end
42
50
  end
43
51
  end
@@ -6,20 +6,30 @@ module AcceptHeaders
6
6
  class Negotiator
7
7
  include Negotiatable
8
8
 
9
- ENCODING_PATTERN = /^\s*(?<encoding>[\w!#$%^&*\-\+{}\\|'.`~]+)\s*$/
9
+ ENCODING_PATTERN = /\A[ \t]*(?<encoding>[A-Za-z0-9!#$%&'*+.^_`|~\-]+)[ \t]*\z/
10
10
  HEADER_PREFIX = 'Accept-Encoding:'
11
11
 
12
12
  def negotiate(supported)
13
- support, match = super(supported)
14
- return nil if support.nil? && match.nil?
15
- begin
16
- return parse(support).first
17
- rescue Encoding::Error
18
- return nil
19
- end
13
+ supported = [*supported]
14
+ identity = Encoding.new('identity')
15
+ explicit_identity = list.any? { |part| part.encoding == 'identity' }
16
+ candidates = explicit_identity ? supported : supported.reject { |support| identity.match(support) }
17
+ support, = super(candidates)
18
+ return Encoding.new(support) if support
19
+ return nil if explicit_identity || !supported.any? { |value| identity.match(value) }
20
+ return nil if list.any? { |part| part.encoding == '*' && part.q.zero? }
21
+ identity
20
22
  end
21
23
 
22
24
  private
25
+ def parse(header)
26
+ if header && header.sub(/\A#{HEADER_PREFIX}\s*/i, '').strip.empty?
27
+ [Encoding.new('identity')]
28
+ else
29
+ super
30
+ end
31
+ end
32
+
23
33
  def no_header
24
34
  [Encoding.new]
25
35
  end
@@ -27,6 +27,10 @@ module AcceptHeaders
27
27
  }
28
28
  end
29
29
 
30
+ def specificity
31
+ [encoding == '*' ? 0 : 1]
32
+ end
33
+
30
34
  def to_s
31
35
  qvalue = (q == 0 || q == 1) ? q.to_i : q
32
36
  "#{encoding};q=#{qvalue}"
@@ -36,9 +40,7 @@ module AcceptHeaders
36
40
  match_data = Negotiator::ENCODING_PATTERN.match(encoding_string)
37
41
  if !match_data
38
42
  false
39
- elsif encoding == match_data[:encoding]
40
- true
41
- elsif match_data[:encoding] == 'identity'
43
+ elsif encoding == match_data[:encoding].downcase
42
44
  true
43
45
  elsif encoding == '*'
44
46
  true
@@ -6,7 +6,8 @@ module AcceptHeaders
6
6
  class Negotiator
7
7
  include Negotiatable
8
8
 
9
- LANGUAGE_TAG_PATTERN = /^\s*(?<primary_tag>[\w]{1,8}|\*)(?:\s*\-\s*(?<subtag>[\w]{1,8}|\*))?\s*$/
9
+ LANGUAGE_TAG_PATTERN = /\A[ \t]*(?<primary_tag>[A-Za-z]{1,8}|\*)(?:-(?<subtag>[A-Za-z0-9]{1,8}(?:-[A-Za-z0-9]{1,8})*))?[ \t]*\z/
10
+ SUPPORTED_LANGUAGE_TAG_PATTERN = /\A[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*\z/
10
11
  HEADER_PREFIX = 'Accept-Language:'
11
12
 
12
13
  def negotiate(supported)
@@ -30,7 +31,7 @@ module AcceptHeaders
30
31
  language_string, q_string = header.split(';', 2)
31
32
  raise Error if language_string.nil?
32
33
  language_range = LANGUAGE_TAG_PATTERN.match(language_string)
33
- raise Error if language_range.nil?
34
+ raise Error if language_range.nil? || (language_range[:primary_tag] == '*' && language_range[:subtag])
34
35
  Language.new(
35
36
  language_range[:primary_tag],
36
37
  language_range[:subtag],
@@ -14,17 +14,7 @@ module AcceptHeaders
14
14
  end
15
15
 
16
16
  def <=>(other)
17
- if q < other.q
18
- -1
19
- elsif q > other.q
20
- 1
21
- elsif (primary_tag == '*' && other.primary_tag != '*') || (subtag == '*' && other.subtag != '*')
22
- -1
23
- elsif (primary_tag != '*' && other.primary_tag == '*') || (subtag != '*' && other.subtag == '*')
24
- 1
25
- else
26
- 0
27
- end
17
+ [q, specificity] <=> [other.q, other.specificity]
28
18
  end
29
19
 
30
20
  def primary_tag=(value)
@@ -53,26 +43,19 @@ module AcceptHeaders
53
43
  end
54
44
 
55
45
  def language_tag
56
- if primary_tag == '*' && (subtag.nil? || subtag == '*')
57
- '*'
58
- else
59
- "#{primary_tag}-#{subtag}"
60
- end
46
+ subtag == '*' ? primary_tag : "#{primary_tag}-#{subtag}"
47
+ end
48
+
49
+ def specificity
50
+ [primary_tag == '*' ? 0 : language_tag.split('-').length]
61
51
  end
62
52
 
63
53
  def match(language_tag_string)
64
- match_data = Negotiator::LANGUAGE_TAG_PATTERN.match(language_tag_string)
65
- if !match_data
66
- false
67
- elsif primary_tag == match_data[:primary_tag] && subtag == match_data[:subtag]
68
- true
69
- elsif primary_tag == match_data[:primary_tag] && subtag == '*'
70
- true
71
- elsif primary_tag == '*'
72
- true
73
- else
74
- false
75
- end
54
+ return false unless language_tag_string.is_a?(String)
55
+ return false unless Negotiator::SUPPORTED_LANGUAGE_TAG_PATTERN.match?(language_tag_string)
56
+ candidate = language_tag_string.downcase
57
+ range = language_tag
58
+ range == '*' || candidate == range || candidate.start_with?("#{range}-")
76
59
  end
77
60
  end
78
61
  end