accept_headers 0.1.0 → 0.3.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: a7e488e8a3433dba0966e71c79d189240b68ae10eca39fa21e62e3a4924fcdb9
4
+ data.tar.gz: cf0955ec1218a983b6b3f79b6ee64255b79094fc6347e07798d20768a9a8ddb9
5
5
  SHA512:
6
- metadata.gz: 25ec2d46da27efaa2b46ba60a48dbdda47fa506930ae6ebbc609282c4494ef46ec575c447ca0d88432d9310f638919cfc1afc72ead05e12691342ecdb8a342d8
7
- data.tar.gz: 1726ae60e312b8cae49d41713bd23391912cec46a327a2e2dd3bed8107db62f8bf3feab28f66a9bf2bb5d443ce44d201f7e0177d8b69f3e2ede989db8c176b16
6
+ metadata.gz: 56a57c4fc145a17435e6fcd0dfb90c074106161a6fdd7169285b6d9efec29ace5dcd9836434778a546cfe6d698148e3920603fc513b791ef78f8f4954e2dbf0d
7
+ data.tar.gz: 22f172cc9de919f571279e971b8f3fa740cd964323c4f09068e186a063c32ec898df1f6a5715e31039070eb94cf39e29b22686768208df2519832cdb32547431
@@ -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,26 @@
1
- ## HEAD
1
+ ## 0.3.0 / October 21, 2026
2
+
3
+ * Validate every supported offer before negotiation and raise `AcceptHeaders::InvalidSupportedValue` for invalid application configuration. Supported offers must be concrete values without quality weights; request parsing remains lenient.
4
+ * Trim surrounding spaces and tabs from supported offers. Language offers such as `" en "` now match where 0.2.0 skipped them.
5
+ * Add `MediaType#content_type` to serialize response media types with their parameters and without quality weights.
6
+ * Preserve HTTP parameter bytes through parsing, matching, and response escaping, including ASCII-compatible multibyte headers and supported strings. A backslash byte introduces a quoted pair even within a Ruby multibyte character; this corrects 0.2.0's character-based parsing.
7
+ * Match identical media parameter bytes across Ruby encoding labels without transcoding. UTF-8, ASCII-8BIT, and ISO-8859-1 strings with the same bytes now match where 0.2.0 could return `nil`; different bytes remain distinct.
8
+ * Match `charset` parameter values with ASCII-only case folding, without the Unicode case folding used in 0.2.0.
9
+ * Document supported-value validation and use parameter-preserving response headers in the Rack example.
10
+
11
+ ## 0.2.0 / October 20, 2026
12
+
13
+ * Replace Travis CI with GitHub Actions covering maintained Ruby 3.3, 3.4, and 4.0, and repair fresh test dependency setup.
14
+ * Follow RFC 9110 negotiation rules and RFC 4647 Basic Filtering for languages.
15
+ * Honor specific quality values and exclusions before wildcard preferences.
16
+ * Match declared media parameters and preserve supported representation parameters instead of copying requested parameters.
17
+ * Preserve quoted delimiters and escapes, validate media tokens, and serialize media parameters before quality values.
18
+ * Handle raw HTTP field bytes without encoding exceptions and preserve quoted non-ASCII values.
19
+ * Parse current quality syntax, including `0.`, `1.`, and case-insensitive `q`, while retaining the documented invalid-quality recovery value.
20
+ * Distinguish missing and empty fields and apply encoding identity fallback and exclusion rules.
21
+ * Support multi-subtag languages and case-insensitive matching. Replace `en-*` header ranges with `en`; bare supported tags remain bare.
22
+ * Compare values by all their fields instead of equating unrelated values with the same sort priority.
23
+ * 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
24
 
3
25
  ## 0.1.0 / April 19, 2019
4
26
 
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,203 @@
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.3.0. See [Upgrading from 0.2.0 to 0.3.0](#upgrading-from-020-to-030) before updating. For older installations, also see [Upgrading from 0.1.0 to 0.2.0](#upgrading-from-010-to-020).
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.3.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
+ match.content_type
63
+ # => "application/json;version=2"
64
+
65
+ response_headers = { 'content-type' => match.content_type }
66
+ response_headers['content-type']
67
+ # => "application/json;version=2"
68
+
69
+ versions.negotiate('application/json')
70
+ # => nil
77
71
  ```
78
72
 
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.
73
+ `#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
74
 
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)
84
-
85
- m = media_types.negotiate('application/json')
86
- puts m.extensions['version'] # returns '2'
87
- ```
75
+ 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. `#content_type` includes these parameters without a quality weight, for use in a response's `Content-Type`. `#to_s` quotes parameter values when needed and writes `q` last.
88
76
 
89
- `#accept?`:
77
+ ### Encodings
90
78
 
91
79
  ```ruby
92
- media_types.accept?('text/html') # true
80
+ encodings = AcceptHeaders::Encoding::Negotiator.new('br, gzip;q=0.8')
81
+ encodings.negotiate(['gzip', 'identity']).encoding
82
+ # => "gzip"
83
+ encodings.accept?('identity')
84
+ # => true
85
+
86
+ AcceptHeaders::Encoding::Negotiator.new('').accept?('gzip')
87
+ # => false
88
+ AcceptHeaders::Encoding::Negotiator.new('').accept?('identity')
89
+ # => true
90
+ AcceptHeaders::Encoding::Negotiator.new(nil).accept?('gzip')
91
+ # => true
93
92
  ```
94
93
 
95
- ### Accept-Encoding
94
+ 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
95
 
97
- `AcceptHeader::Encoding::Encoding`:
96
+ 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
97
 
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)
98
+ ### Languages
102
99
 
103
- encodings.list
100
+ ```ruby
101
+ languages = AcceptHeaders::Language::Negotiator.new('zh-Hant-TW, en;q=0.8')
102
+ languages.negotiate(['en-US', 'zh-Hant-TW']).language_tag
103
+ # => "zh-hant-tw"
104
+ languages.accept?('en-GB')
105
+ # => true
106
+ languages.accept?('eng')
107
+ # => false
108
+
109
+ AcceptHeaders::Language::Negotiator.new('en').negotiate('en').language_tag
110
+ # => "en"
111
+ ```
104
112
 
105
- # Returns:
113
+ 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.
106
114
 
107
- [
108
- AcceptHeaders::Encoding.new('gzip'),
109
- AcceptHeaders::Encoding.new('compress', q: 0.8),
110
- AcceptHeaders::Encoding.new('deflate', q: 0.5)
111
- ]
112
- ```
115
+ 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`.
113
116
 
114
- `#negotiate`:
117
+ `#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-*`.
115
118
 
116
- ```ruby
117
- encodings.negotiate(['gzip', 'compress'])
119
+ ### Missing, empty, and malformed headers
118
120
 
119
- # Returns this equivalent:
121
+ 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.
120
122
 
121
- AcceptHeader::Encoding.new('gzip')
122
- ```
123
+ 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. For headers in ASCII-compatible Ruby encodings, media parameter parsing treats a backslash byte as a quoted-pair escape even when Ruby considers it part of a multibyte character. Headers and supported strings follow the same HTTP byte syntax, without transcoding.
123
124
 
124
- `#accept?`:
125
+ 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.
125
126
 
126
- ```ruby
127
- encodings.accept?('gzip') # true
127
+ `#to_s` produces a normalized preference header, including quality values. Use `#content_type`, `#encoding`, or `#language_tag` for response headers. `#media_range` contains only the media type and subtype, without parameters. With `==`, objects compare equal only when their class, type-specific fields, and quality values agree. Sorting order does not imply equality.
128
128
 
129
- # Identity is accepted as long as it's not explicitly rejected 'identity;q=0'
129
+ ### Supported values
130
130
 
131
- encodings.accept?('identity') # true
132
- ```
131
+ Supported strings must use an ASCII-compatible Ruby encoding. Media parameter parsing, matching, and response escaping interpret them as HTTP bytes, without transcoding. A backslash byte introduces a quoted pair even when Ruby considers it part of a multibyte character. Parameter values compare byte for byte regardless of Ruby encoding labels, except that `charset` ignores ASCII letter case. UTF-8 parameter values retain their encoding; frozen offers are not modified.
133
132
 
134
- ### Accept-Language
133
+ `#negotiate` and `#accept?` validate every supported value before matching, even when the request header is empty or an earlier offer matches. Declare concrete media types, coding tokens, or language tags. Wildcards, lists within one string, header name prefixes, quality weights such as `;q=1`, malformed syntax, and non-string values raise `AcceptHeaders::InvalidSupportedValue`, a subclass of `ArgumentError`. Invalid Ruby string encodings and prohibited control characters also raise this error. Quoted media parameter values can contain literal `q=` text.
135
134
 
136
- `Accept::Language::Negotiator`:
135
+ This error indicates invalid application configuration. A valid offer that the client does not accept still returns `nil` from `#negotiate` and `false` from `#accept?`. Passing `nil` or an empty array declares no offers and returns those same results; an array containing `nil` is invalid. Request header parsing remains lenient as described above.
137
136
 
138
- ```ruby
139
- accept_language = 'Accept-Language: en-*, en-us, *;q=0.8'
140
- languages = AcceptHeaders::Language::Negotiator.new(accept_language)
137
+ ## Rack middleware
141
138
 
142
- languages.list
139
+ The middleware adds negotiators to the request environment. The application selects representations and creates the response:
143
140
 
144
- # Returns:
141
+ ```ruby
142
+ require 'accept_headers/middleware'
145
143
 
146
- [
147
- AcceptHeaders::Language.new('en', 'us'),
148
- AcceptHeaders::Language.new('en', '*'),
149
- AcceptHeaders::Language.new('*', '*', q: 0.8)
150
- ]
144
+ use AcceptHeaders::Middleware
145
+ run lambda { |env|
146
+ media = env['accept_headers.media_types'].negotiate('application/json;version=2')
147
+ encoding = env['accept_headers.encodings'].negotiate('identity')
148
+ language = env['accept_headers.languages'].negotiate(['en-US', 'en-GB'])
149
+ vary = 'Accept, Accept-Encoding, Accept-Language'
150
+
151
+ if media && encoding && language
152
+ headers = {
153
+ 'content-type' => media.content_type,
154
+ 'content-language' => language.language_tag,
155
+ 'vary' => vary
156
+ }
157
+ [200, headers, ['{"message":"Hello"}']]
158
+ else
159
+ [406, { 'content-type' => 'text/plain', 'vary' => vary }, ['Not acceptable']]
160
+ end
161
+ }
151
162
  ```
152
163
 
153
- `#negotiate`:
164
+ This example supports version 2 of 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.
154
165
 
155
- ```ruby
156
- languages.negotiate(['en-us', 'zh-Hant'])
166
+ ## Upgrading from 0.2.0 to 0.3.0
157
167
 
158
- # Returns this equivalent:
168
+ Version 0.3.0 validates supported offers before negotiation and adds response serialization:
159
169
 
160
- AcceptHeaders::Language.new('en', 'us')
161
- ```
170
+ - Declare concrete supported values without wildcards, header prefixes, lists within one string, or quality weights. Every invalid offer raises `AcceptHeaders::InvalidSupportedValue`, even if an earlier offer matches. Request parsing remains lenient.
171
+ - Surrounding spaces and tabs are trimmed from supported offers. Language offers such as `" en "` now match where 0.2.0 skipped them.
172
+ - Media parameters follow HTTP byte syntax, including quoted-pair escapes inside Ruby multibyte characters. Identical parameter bytes match across Ruby encoding labels without transcoding. `charset` case folding applies only to ASCII letters.
173
+ - Use `MediaType#content_type` for response headers to retain parameters without a quality weight. `#to_s` still includes quality values, and `#media_range` still omits parameters.
162
174
 
163
- `#accept?`:
175
+ ## Upgrading from 0.1.0 to 0.2.0
164
176
 
165
- ```ruby
166
- languages.accept?('en-gb') # true
167
- ```
177
+ Version 0.2.0 corrects behavior from version 0.1.0:
168
178
 
169
- ## Rack Middleware
179
+ - Explicit exclusions and specific media preferences now take precedence over broader wildcard matches.
180
+ - Parameterized media requests require matching supported parameters. Replace `negotiate('application/json')` with the versioned representations your application actually supports.
181
+ - Media parameters follow RFC 9110, including parameters after `q`. Serialization places `q` last and quotes values when needed.
182
+ - Missing and empty headers have distinct meanings. Identity fallback follows the encoding rules above.
183
+ - Replace `en-*` header ranges with `en`. Multi-subtag language ranges now work, and bare supported tags remain bare.
184
+ - Equality compares actual values, including parameters. Two different values with equal sort priority are no longer equal.
170
185
 
171
- Add the middleware:
186
+ The public negotiator classes, constructors, and `#extensions` accessor remain available. These corrections can change selection results for callers that depended on the previous behavior.
172
187
 
173
- ```ruby
174
- require 'accept_headers/middleware'
175
- use AcceptHeaders::Middleware
176
- run YourApp
177
- ```
188
+ ## Development
178
189
 
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.
190
+ Install dependencies with `bundle install`, then run the complete suite:
180
191
 
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
192
+ ```sh
193
+ bundle exec rake test
212
194
  ```
213
195
 
196
+ 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.
197
+
214
198
  ## Contributing
215
199
 
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
200
+ 1. Fork [kamui/accept_headers](https://github.com/kamui/accept_headers).
201
+ 2. Create a branch for your change.
202
+ 3. Add regression tests and run the suite.
203
+ 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,35 @@ 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 = validate_supported_values(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 valid_supported_value?(value)
26
+ match = ENCODING_PATTERN.match(value.b)
27
+ match && match[:encoding] != '*'
28
+ end
29
+
30
+ def parse(header)
31
+ if header && header.sub(/\A#{HEADER_PREFIX}\s*/i, '').strip.empty?
32
+ [Encoding.new('identity')]
33
+ else
34
+ super
35
+ end
36
+ end
37
+
23
38
  def no_header
24
39
  [Encoding.new]
25
40
  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
@@ -0,0 +1,3 @@
1
+ module AcceptHeaders
2
+ class InvalidSupportedValue < ArgumentError; end
3
+ end
@@ -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)
@@ -20,6 +21,10 @@ module AcceptHeaders
20
21
  end
21
22
 
22
23
  private
24
+ def valid_supported_value?(value)
25
+ SUPPORTED_LANGUAGE_TAG_PATTERN.match(value.b.strip)
26
+ end
27
+
23
28
  def no_header
24
29
  [Language.new]
25
30
  end
@@ -30,7 +35,7 @@ module AcceptHeaders
30
35
  language_string, q_string = header.split(';', 2)
31
36
  raise Error if language_string.nil?
32
37
  language_range = LANGUAGE_TAG_PATTERN.match(language_string)
33
- raise Error if language_range.nil?
38
+ raise Error if language_range.nil? || (language_range[:primary_tag] == '*' && language_range[:subtag])
34
39
  Language.new(
35
40
  language_range[:primary_tag],
36
41
  language_range[:subtag],