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 +4 -4
- data/.github/dependabot.yml +8 -0
- data/.github/workflows/ci.yml +23 -0
- data/CHANGELOG.md +23 -1
- data/Gemfile +2 -1
- data/README.md +134 -151
- data/accept_headers.gemspec +2 -2
- data/lib/accept_headers/acceptable.rb +8 -0
- data/lib/accept_headers/encoding/negotiator.rb +23 -8
- data/lib/accept_headers/encoding.rb +5 -3
- data/lib/accept_headers/invalid_supported_value.rb +3 -0
- data/lib/accept_headers/language/negotiator.rb +7 -2
- data/lib/accept_headers/language.rb +11 -28
- data/lib/accept_headers/media_type/negotiator.rb +32 -19
- data/lib/accept_headers/media_type.rb +35 -14
- data/lib/accept_headers/negotiatable.rb +70 -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 +73 -50
- data/spec/language_spec.rb +14 -4
- data/spec/malformed_spec.rb +178 -0
- data/spec/media_type/negotiator_spec.rb +97 -19
- data/spec/media_type_spec.rb +117 -3
- data/spec/middleware_spec.rb +5 -4
- data/spec/readme_spec.rb +67 -0
- data/spec/rejection_spec.rb +62 -0
- data/spec/spec_helper.rb +1 -3
- data/spec/supported_values_spec.rb +158 -0
- metadata +20 -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: a7e488e8a3433dba0966e71c79d189240b68ae10eca39fa21e62e3a4924fcdb9
|
|
4
|
+
data.tar.gz: cf0955ec1218a983b6b3f79b6ee64255b79094fc6347e07798d20768a9a8ddb9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56a57c4fc145a17435e6fcd0dfb90c074106161a6fdd7169285b6d9efec29ace5dcd9836434778a546cfe6d698148e3920603fc513b791ef78f8f4954e2dbf0d
|
|
7
|
+
data.tar.gz: 22f172cc9de919f571279e971b8f3fa740cd964323c4f09068e186a063c32ec898df1f6a5715e31039070eb94cf39e29b22686768208df2519832cdb32547431
|
|
@@ -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
|
-
##
|
|
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
|
|
15
|
+
gem "rack"
|
|
15
16
|
end
|
data/README.md
CHANGED
|
@@ -1,220 +1,203 @@
|
|
|
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.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
|
-
|
|
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.3.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
|
+
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
+
### Encodings
|
|
90
78
|
|
|
91
79
|
```ruby
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`#
|
|
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
|
-
|
|
117
|
-
encodings.negotiate(['gzip', 'compress'])
|
|
119
|
+
### Missing, empty, and malformed headers
|
|
118
120
|
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
+
### Supported values
|
|
130
130
|
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
The middleware adds negotiators to the request environment. The application selects representations and creates the response:
|
|
143
140
|
|
|
144
|
-
|
|
141
|
+
```ruby
|
|
142
|
+
require 'accept_headers/middleware'
|
|
145
143
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
languages.negotiate(['en-us', 'zh-Hant'])
|
|
166
|
+
## Upgrading from 0.2.0 to 0.3.0
|
|
157
167
|
|
|
158
|
-
|
|
168
|
+
Version 0.3.0 validates supported offers before negotiation and adds response serialization:
|
|
159
169
|
|
|
160
|
-
AcceptHeaders::
|
|
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
|
-
|
|
175
|
+
## Upgrading from 0.1.0 to 0.2.0
|
|
164
176
|
|
|
165
|
-
|
|
166
|
-
languages.accept?('en-gb') # true
|
|
167
|
-
```
|
|
177
|
+
Version 0.2.0 corrects behavior from version 0.1.0:
|
|
168
178
|
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
require 'accept_headers/middleware'
|
|
175
|
-
use AcceptHeaders::Middleware
|
|
176
|
-
run YourApp
|
|
177
|
-
```
|
|
188
|
+
## Development
|
|
178
189
|
|
|
179
|
-
|
|
190
|
+
Install dependencies with `bundle install`, then run the complete suite:
|
|
180
191
|
|
|
181
|
-
```
|
|
182
|
-
|
|
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
|
|
217
|
-
2. Create
|
|
218
|
-
3.
|
|
219
|
-
4.
|
|
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.
|
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,35 @@ 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 = 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
|
|
@@ -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)
|
|
@@ -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],
|