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 +4 -4
- data/.github/dependabot.yml +8 -0
- data/.github/workflows/ci.yml +23 -0
- data/CHANGELOG.md +13 -1
- data/Gemfile +2 -1
- data/README.md +115 -155
- data/accept_headers.gemspec +2 -2
- data/lib/accept_headers/acceptable.rb +8 -0
- data/lib/accept_headers/encoding/negotiator.rb +18 -8
- data/lib/accept_headers/encoding.rb +5 -3
- data/lib/accept_headers/language/negotiator.rb +3 -2
- data/lib/accept_headers/language.rb +11 -28
- data/lib/accept_headers/media_type/negotiator.rb +18 -19
- data/lib/accept_headers/media_type.rb +28 -15
- data/lib/accept_headers/negotiatable.rb +48 -28
- data/lib/accept_headers/version.rb +1 -1
- data/spec/encoding/negotiator_spec.rb +60 -6
- data/spec/encoding_spec.rb +8 -0
- data/spec/equality_spec.rb +70 -0
- data/spec/language/negotiator_spec.rb +72 -50
- data/spec/language_spec.rb +14 -4
- data/spec/malformed_spec.rb +121 -0
- data/spec/media_type/negotiator_spec.rb +97 -19
- data/spec/media_type_spec.rb +39 -3
- data/spec/middleware_spec.rb +5 -4
- data/spec/readme_spec.rb +66 -0
- data/spec/rejection_spec.rb +62 -0
- data/spec/spec_helper.rb +1 -3
- metadata +17 -11
- data/.travis.yml +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f3e0dda15c77d1bf57df59c16d27f829e3fadf8868f63717e59bd65247b0ec3f
|
|
4
|
+
data.tar.gz: 0baaeba925e9b4582f43fd3f699ae03debd44a8bb7d31b4b4501f178984ccb2e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b76e1a583842a4952c99b953fa406edc682bd4f5e30eb07f383f07f42cb89b71f4d7b6e31f4010ad1f375406b82ac65b2462b2672529a689e18fd31ed562c9df
|
|
7
|
+
data.tar.gz: c4920549cbed068d58abec12c24058ea3d8cc8b6c4d6768374f698d7e4f544f4c03c4d831e67b83228b237e0136d384dc9877b9b4c772fa3ce1a48c9419299e1
|
|
@@ -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
|
-
##
|
|
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
|
|
15
|
+
gem "rack"
|
|
15
16
|
end
|
data/README.md
CHANGED
|
@@ -1,220 +1,180 @@
|
|
|
1
|
-
[](https://github.com/kamui/accept_headers/actions/workflows/ci.yml)
|
|
2
2
|
|
|
3
3
|
# AcceptHeaders
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
$ bundle
|
|
38
|
-
|
|
39
|
-
Or install it yourself as:
|
|
21
|
+
## Usage
|
|
40
22
|
|
|
41
|
-
|
|
23
|
+
```ruby
|
|
24
|
+
require 'accept_headers'
|
|
25
|
+
```
|
|
42
26
|
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
+
### Media types
|
|
48
32
|
|
|
49
33
|
```ruby
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
38
|
+
media_types.negotiate(['text/html', 'application/json']).media_range
|
|
39
|
+
# => "application/json"
|
|
54
40
|
|
|
55
|
-
|
|
41
|
+
media_types.accept?('text/html')
|
|
42
|
+
# => true
|
|
56
43
|
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
+
Media parameters constrain the match. Declare each representation that your application actually supports:
|
|
69
51
|
|
|
70
52
|
```ruby
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
puts m.extensions['version'] # returns '2'
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
`#accept?`:
|
|
71
|
+
### Encodings
|
|
90
72
|
|
|
91
73
|
```ruby
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
```
|
|
113
|
+
### Missing, empty, and malformed headers
|
|
123
114
|
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
123
|
+
## Rack middleware
|
|
135
124
|
|
|
136
|
-
|
|
125
|
+
The middleware adds negotiators to the request environment. The application selects representations and creates the response:
|
|
137
126
|
|
|
138
127
|
```ruby
|
|
139
|
-
|
|
140
|
-
languages = AcceptHeaders::Language::Negotiator.new(accept_language)
|
|
141
|
-
|
|
142
|
-
languages.list
|
|
143
|
-
|
|
144
|
-
# Returns:
|
|
128
|
+
require 'accept_headers/middleware'
|
|
145
129
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
+
## Upgrading from 0.1.0 to 0.2.0
|
|
159
153
|
|
|
160
|
-
|
|
161
|
-
```
|
|
154
|
+
Version 0.2.0 corrects behavior from version 0.1.0:
|
|
162
155
|
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
165
|
+
## Development
|
|
170
166
|
|
|
171
|
-
|
|
167
|
+
Install dependencies with `bundle install`, then run the complete suite:
|
|
172
168
|
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
use AcceptHeaders::Middleware
|
|
176
|
-
run YourApp
|
|
169
|
+
```sh
|
|
170
|
+
bundle exec rake test
|
|
177
171
|
```
|
|
178
172
|
|
|
179
|
-
|
|
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
|
|
217
|
-
2. Create
|
|
218
|
-
3.
|
|
219
|
-
4.
|
|
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.
|
data/accept_headers.gemspec
CHANGED
|
@@ -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
|
|
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", "~>
|
|
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 =
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|