accept_headers 0.2.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/CHANGELOG.md +11 -1
- data/README.md +31 -8
- data/lib/accept_headers/encoding/negotiator.rb +6 -1
- data/lib/accept_headers/invalid_supported_value.rb +3 -0
- data/lib/accept_headers/language/negotiator.rb +4 -0
- data/lib/accept_headers/media_type/negotiator.rb +15 -1
- data/lib/accept_headers/media_type.rb +11 -3
- data/lib/accept_headers/negotiatable.rb +27 -5
- data/lib/accept_headers/version.rb +1 -1
- data/spec/encoding/negotiator_spec.rb +1 -1
- data/spec/language/negotiator_spec.rb +3 -2
- data/spec/malformed_spec.rb +57 -0
- data/spec/media_type_spec.rb +78 -0
- data/spec/readme_spec.rb +2 -1
- data/spec/supported_values_spec.rb +158 -0
- metadata +4 -1
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
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
## 0.
|
|
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
|
|
2
12
|
|
|
3
13
|
* Replace Travis CI with GitHub Actions covering maintained Ruby 3.3, 3.4, and 4.0, and repair fresh test dependency setup.
|
|
4
14
|
* Follow RFC 9110 negotiation rules and RFC 4647 Basic Filtering for languages.
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
|
-
This README describes version 0.
|
|
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
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.
|
|
10
10
|
|
|
@@ -15,7 +15,7 @@ CI covers the maintained Ruby series: 3.3, 3.4, and 4.0. Older Ruby versions rem
|
|
|
15
15
|
Add the gem to your Gemfile and run `bundle install`:
|
|
16
16
|
|
|
17
17
|
```ruby
|
|
18
|
-
gem 'accept_headers', '~> 0.
|
|
18
|
+
gem 'accept_headers', '~> 0.3.0'
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
## Usage
|
|
@@ -59,6 +59,12 @@ match.media_range
|
|
|
59
59
|
# => "application/json"
|
|
60
60
|
match.extensions
|
|
61
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"
|
|
62
68
|
|
|
63
69
|
versions.negotiate('application/json')
|
|
64
70
|
# => nil
|
|
@@ -66,7 +72,7 @@ versions.negotiate('application/json')
|
|
|
66
72
|
|
|
67
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.
|
|
68
74
|
|
|
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.
|
|
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.
|
|
70
76
|
|
|
71
77
|
### Encodings
|
|
72
78
|
|
|
@@ -114,11 +120,19 @@ Use `en` to match English variants. Extended wildcard ranges such as `en-*` are
|
|
|
114
120
|
|
|
115
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.
|
|
116
122
|
|
|
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.
|
|
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.
|
|
118
124
|
|
|
119
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.
|
|
120
126
|
|
|
121
|
-
`#to_s` produces a normalized preference header, including quality values. Use `#
|
|
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
|
+
|
|
129
|
+
### Supported values
|
|
130
|
+
|
|
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.
|
|
132
|
+
|
|
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.
|
|
134
|
+
|
|
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.
|
|
122
136
|
|
|
123
137
|
## Rack middleware
|
|
124
138
|
|
|
@@ -129,14 +143,14 @@ require 'accept_headers/middleware'
|
|
|
129
143
|
|
|
130
144
|
use AcceptHeaders::Middleware
|
|
131
145
|
run lambda { |env|
|
|
132
|
-
media = env['accept_headers.media_types'].negotiate('application/json')
|
|
146
|
+
media = env['accept_headers.media_types'].negotiate('application/json;version=2')
|
|
133
147
|
encoding = env['accept_headers.encodings'].negotiate('identity')
|
|
134
148
|
language = env['accept_headers.languages'].negotiate(['en-US', 'en-GB'])
|
|
135
149
|
vary = 'Accept, Accept-Encoding, Accept-Language'
|
|
136
150
|
|
|
137
151
|
if media && encoding && language
|
|
138
152
|
headers = {
|
|
139
|
-
'content-type' => media.
|
|
153
|
+
'content-type' => media.content_type,
|
|
140
154
|
'content-language' => language.language_tag,
|
|
141
155
|
'vary' => vary
|
|
142
156
|
}
|
|
@@ -147,7 +161,16 @@ run lambda { |env|
|
|
|
147
161
|
}
|
|
148
162
|
```
|
|
149
163
|
|
|
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.
|
|
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.
|
|
165
|
+
|
|
166
|
+
## Upgrading from 0.2.0 to 0.3.0
|
|
167
|
+
|
|
168
|
+
Version 0.3.0 validates supported offers before negotiation and adds response serialization:
|
|
169
|
+
|
|
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.
|
|
151
174
|
|
|
152
175
|
## Upgrading from 0.1.0 to 0.2.0
|
|
153
176
|
|
|
@@ -10,7 +10,7 @@ module AcceptHeaders
|
|
|
10
10
|
HEADER_PREFIX = 'Accept-Encoding:'
|
|
11
11
|
|
|
12
12
|
def negotiate(supported)
|
|
13
|
-
supported =
|
|
13
|
+
supported = validate_supported_values(supported)
|
|
14
14
|
identity = Encoding.new('identity')
|
|
15
15
|
explicit_identity = list.any? { |part| part.encoding == 'identity' }
|
|
16
16
|
candidates = explicit_identity ? supported : supported.reject { |support| identity.match(support) }
|
|
@@ -22,6 +22,11 @@ module AcceptHeaders
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
private
|
|
25
|
+
def valid_supported_value?(value)
|
|
26
|
+
match = ENCODING_PATTERN.match(value.b)
|
|
27
|
+
match && match[:encoding] != '*'
|
|
28
|
+
end
|
|
29
|
+
|
|
25
30
|
def parse(header)
|
|
26
31
|
if header && header.sub(/\A#{HEADER_PREFIX}\s*/i, '').strip.empty?
|
|
27
32
|
[Encoding.new('identity')]
|
|
@@ -17,6 +17,20 @@ module AcceptHeaders
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
private
|
|
20
|
+
def valid_supported_value?(value)
|
|
21
|
+
media_range_string, *parameters = split_quoted(value.b, ';')
|
|
22
|
+
media_range = MEDIA_TYPE_PATTERN.match(media_range_string)
|
|
23
|
+
return false unless media_range && media_range[:subtype]
|
|
24
|
+
return false if media_range[:type] == '*' || media_range[:subtype] == '*'
|
|
25
|
+
parameters.all? do |parameter|
|
|
26
|
+
next true if parameter.strip.empty?
|
|
27
|
+
match = PARAMS_PATTERN.match(parameter)
|
|
28
|
+
match && match[:attribute].downcase != 'q'
|
|
29
|
+
end
|
|
30
|
+
rescue ParseError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
20
34
|
def no_header
|
|
21
35
|
[MediaType.new]
|
|
22
36
|
end
|
|
@@ -49,7 +63,7 @@ module AcceptHeaders
|
|
|
49
63
|
raise ParseError unless param
|
|
50
64
|
value = param[:value].force_encoding(part.encoding)
|
|
51
65
|
if value.start_with?('"')
|
|
52
|
-
value = value[1...-1].gsub(/\\(.)/m, '\\1')
|
|
66
|
+
value = value.b[1...-1].gsub(/\\(.)/m, '\\1').force_encoding(part.encoding)
|
|
53
67
|
end
|
|
54
68
|
extensions[param[:attribute].downcase] = value
|
|
55
69
|
end
|
|
@@ -63,17 +63,21 @@ module AcceptHeaders
|
|
|
63
63
|
|
|
64
64
|
def to_s
|
|
65
65
|
qvalue = (q == 0 || q == 1) ? q.to_i : q
|
|
66
|
+
content_type.concat(";q=#{qvalue}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def content_type
|
|
66
70
|
string = media_range
|
|
67
71
|
if extensions.size > 0
|
|
68
72
|
extensions.each do |key, value|
|
|
69
73
|
value = value.to_s
|
|
70
|
-
unless /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.match(value)
|
|
71
|
-
value = '"' + value.gsub(/["\\]/) { |character| "\\#{character}" } + '"'
|
|
74
|
+
unless /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.match(value.b)
|
|
75
|
+
value = '"' + value.b.gsub(/["\\]/) { |character| "\\#{character}" }.force_encoding(value.encoding) + '"'
|
|
72
76
|
end
|
|
73
77
|
string.concat(";#{key}=#{value}")
|
|
74
78
|
end
|
|
75
79
|
end
|
|
76
|
-
string
|
|
80
|
+
string
|
|
77
81
|
end
|
|
78
82
|
|
|
79
83
|
def media_range
|
|
@@ -94,6 +98,10 @@ module AcceptHeaders
|
|
|
94
98
|
|
|
95
99
|
extensions.all? do |name, value|
|
|
96
100
|
supported_value = candidate.extensions[name]
|
|
101
|
+
if value.is_a?(String) && supported_value.is_a?(String)
|
|
102
|
+
value = value.b
|
|
103
|
+
supported_value = supported_value.b
|
|
104
|
+
end
|
|
97
105
|
if name == 'charset'
|
|
98
106
|
supported_value && value.casecmp?(supported_value)
|
|
99
107
|
else
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require "accept_headers/invalid_supported_value"
|
|
2
|
+
|
|
1
3
|
module AcceptHeaders
|
|
2
4
|
module Negotiatable
|
|
3
5
|
class Error < StandardError; end
|
|
@@ -7,6 +9,7 @@ module AcceptHeaders
|
|
|
7
9
|
|
|
8
10
|
Q_PATTERN = /\A[ \t]*q[ \t]*=[ \t]*(?<q>.*?)[ \t]*\z/im
|
|
9
11
|
QVALUE_PATTERN = /\A(?:0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?)\z/
|
|
12
|
+
SUPPORTED_CONTROL_PATTERN = /[\x00-\x08\x0A-\x1F\x7F]/n
|
|
10
13
|
|
|
11
14
|
def initialize(header)
|
|
12
15
|
header = header.b if header && !header.valid_encoding?
|
|
@@ -14,8 +17,9 @@ module AcceptHeaders
|
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
def negotiate(supported)
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
supported = validate_supported_values(supported)
|
|
21
|
+
candidates = supported.each_with_index.map do |support, support_index|
|
|
22
|
+
matches = list.each_with_index.select { |part, _| part.match(support.strip) }
|
|
19
23
|
part, header_index = matches.max_by do |match, _|
|
|
20
24
|
[match.specificity, match.q.zero? ? 1 : 0, match.q]
|
|
21
25
|
end
|
|
@@ -37,6 +41,23 @@ module AcceptHeaders
|
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
private
|
|
44
|
+
def validate_supported_values(supported)
|
|
45
|
+
values = if supported.nil?
|
|
46
|
+
[]
|
|
47
|
+
elsif supported.is_a?(Array)
|
|
48
|
+
supported
|
|
49
|
+
else
|
|
50
|
+
[supported]
|
|
51
|
+
end
|
|
52
|
+
values.each do |value|
|
|
53
|
+
unless value.is_a?(String) && value.valid_encoding? && value.encoding.ascii_compatible? &&
|
|
54
|
+
!SUPPORTED_CONTROL_PATTERN.match(value.b) && valid_supported_value?(value)
|
|
55
|
+
raise InvalidSupportedValue, "Invalid supported value: #{value.inspect}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
values
|
|
59
|
+
end
|
|
60
|
+
|
|
40
61
|
def no_header
|
|
41
62
|
raise NotImplementedError.new("#no_header is not implemented")
|
|
42
63
|
end
|
|
@@ -68,10 +89,10 @@ module AcceptHeaders
|
|
|
68
89
|
|
|
69
90
|
def split_quoted(value, delimiter)
|
|
70
91
|
parts = []
|
|
71
|
-
part = +""
|
|
92
|
+
part = +"".b
|
|
72
93
|
quoted = false
|
|
73
94
|
escaped = false
|
|
74
|
-
value.each_char do |character|
|
|
95
|
+
value.b.each_char do |character|
|
|
75
96
|
if escaped
|
|
76
97
|
escaped = false
|
|
77
98
|
elsif quoted && character == "\\"
|
|
@@ -80,13 +101,14 @@ module AcceptHeaders
|
|
|
80
101
|
quoted = !quoted
|
|
81
102
|
elsif !quoted && character == delimiter
|
|
82
103
|
parts << part
|
|
83
|
-
part = +""
|
|
104
|
+
part = +"".b
|
|
84
105
|
next
|
|
85
106
|
end
|
|
86
107
|
part << character
|
|
87
108
|
end
|
|
88
109
|
raise ParseError if quoted || escaped
|
|
89
110
|
parts << part
|
|
111
|
+
parts.map { |segment| segment.force_encoding(value.encoding) }
|
|
90
112
|
end
|
|
91
113
|
|
|
92
114
|
def parse_q(header)
|
|
@@ -139,7 +139,7 @@ describe AcceptHeaders::Encoding::Negotiator do
|
|
|
139
139
|
it "requires supported names to be complete HTTP tokens" do
|
|
140
140
|
negotiator = subject.new('*')
|
|
141
141
|
['gzip\\bad', 'gzip{bad}', "gzip\nidentity", 'gzip,br'].each do |coding|
|
|
142
|
-
negotiator.negotiate(coding)
|
|
142
|
+
assert_raises(AcceptHeaders::InvalidSupportedValue) { negotiator.negotiate(coding) }
|
|
143
143
|
end
|
|
144
144
|
end
|
|
145
145
|
end
|
|
@@ -95,9 +95,10 @@ describe AcceptHeaders::Language::Negotiator do
|
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
it "requires concrete supported language tags" do
|
|
98
|
-
['*', 'en-*', 'en_US', 'en;q=1'
|
|
99
|
-
subject.new('*').accept?(support)
|
|
98
|
+
['*', 'en-*', 'en_US', 'en;q=1'].each do |support|
|
|
99
|
+
assert_raises(AcceptHeaders::InvalidSupportedValue) { subject.new('*').accept?(support) }
|
|
100
100
|
end
|
|
101
|
+
subject.new('*').accept?(nil).must_equal false
|
|
101
102
|
end
|
|
102
103
|
|
|
103
104
|
it "returns nil when no supported language matches" do
|
data/spec/malformed_spec.rb
CHANGED
|
@@ -53,6 +53,63 @@ class MalformedHeaderSpec < Minitest::Test
|
|
|
53
53
|
assert_equal item.to_h, AcceptHeaders::MediaType::Negotiator.new(item.to_s).list.first.to_h
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
def test_multibyte_headers_decode_quoted_pairs_as_http_bytes
|
|
57
|
+
{
|
|
58
|
+
[0x95, 0x5c, 0x5c] => [0x95, 0x5c],
|
|
59
|
+
[0x95, 0x5c, 0x5c, 0x61] => [0x95, 0x5c, 0x61],
|
|
60
|
+
[0x95, 0x5c, 0x61] => [0x95, 0x61],
|
|
61
|
+
[0x95, 0x5c, 0x22, 0x61] => [0x95, 0x22, 0x61]
|
|
62
|
+
}.each do |bytes, expected|
|
|
63
|
+
wire = 'text/plain;note="'.b + bytes.pack('C*') + '";q=0.8,application/json;q=0.5'
|
|
64
|
+
header = wire.dup.force_encoding('Shift_JIS').freeze
|
|
65
|
+
assert header.valid_encoding?
|
|
66
|
+
binary = AcceptHeaders::MediaType::Negotiator.new(wire)
|
|
67
|
+
tagged = AcceptHeaders::MediaType::Negotiator.new(header)
|
|
68
|
+
|
|
69
|
+
assert_equal binary.list.map(&:media_range), tagged.list.map(&:media_range)
|
|
70
|
+
assert_equal binary.list.first.extensions['note'].bytes, tagged.list.first.extensions['note'].bytes
|
|
71
|
+
assert_equal expected, tagged.list.first.extensions['note'].bytes
|
|
72
|
+
assert_equal [0.8, 0.5], tagged.list.map(&:q)
|
|
73
|
+
[wire.split(';q=').first, 'text/plain;note="other"', 'application/json'].each do |offer|
|
|
74
|
+
assert_equal binary.accept?(offer), tagged.accept?(offer)
|
|
75
|
+
assert_equal [binary.negotiate(offer)&.content_type&.bytes], [tagged.negotiate(offer)&.content_type&.bytes]
|
|
76
|
+
end
|
|
77
|
+
assert_equal wire.bytes, header.bytes
|
|
78
|
+
assert_equal Encoding::Shift_JIS, header.encoding
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_multibyte_headers_reject_unterminated_http_quoted_pairs
|
|
83
|
+
['"', '",application/json'].each do |suffix|
|
|
84
|
+
wire = 'text/plain;note="'.b + [0x95, 0x5c].pack('C*') + suffix
|
|
85
|
+
header = wire.dup.force_encoding('Shift_JIS').freeze
|
|
86
|
+
assert header.valid_encoding?
|
|
87
|
+
|
|
88
|
+
[wire, header].each do |input|
|
|
89
|
+
matcher = AcceptHeaders::MediaType::Negotiator.new(input)
|
|
90
|
+
assert_empty matcher.list
|
|
91
|
+
refute matcher.accept?('application/json')
|
|
92
|
+
assert_nil matcher.negotiate('text/plain')
|
|
93
|
+
end
|
|
94
|
+
assert_equal wire.bytes, header.bytes
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def test_utf8_header_quoted_pairs_preserve_values_and_encoding
|
|
99
|
+
header = 'text/plain;note="日本語; café\\"x\\\\y";q=0.8'.freeze
|
|
100
|
+
tagged = AcceptHeaders::MediaType::Negotiator.new(header)
|
|
101
|
+
binary = AcceptHeaders::MediaType::Negotiator.new(header.b)
|
|
102
|
+
item = tagged.list.first
|
|
103
|
+
|
|
104
|
+
assert_equal '日本語; café"x\\y', item.extensions['note']
|
|
105
|
+
assert_equal Encoding::UTF_8, item.extensions['note'].encoding
|
|
106
|
+
assert_equal 0.8, item.q
|
|
107
|
+
assert_equal binary.list.first.extensions['note'].bytes, item.extensions['note'].bytes
|
|
108
|
+
assert_equal item.to_h, AcceptHeaders::MediaType::Negotiator.new(item.to_s).list.first.to_h
|
|
109
|
+
assert_equal item.content_type, tagged.negotiate(item.content_type).content_type
|
|
110
|
+
assert_equal Encoding::UTF_8, header.encoding
|
|
111
|
+
end
|
|
112
|
+
|
|
56
113
|
def test_invalid_media_ranges_do_not_match_partial_lines_or_non_token_characters
|
|
57
114
|
['*/json', 'text/html/extra', 'text/ht{ml}', 'text/ht\\ml', "text/html\nnonsense", 'text /html'].each do |range|
|
|
58
115
|
assert_empty AcceptHeaders::MediaType::Negotiator.new(range).list, range
|
data/spec/media_type_spec.rb
CHANGED
|
@@ -104,6 +104,84 @@ describe AcceptHeaders::MediaType do
|
|
|
104
104
|
subject.new('text', 'html', extensions: { 'level' => '1' }).media_range.must_equal "text/html"
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
describe "#content_type" do
|
|
108
|
+
it "outputs type, subtype, and parameters without the quality weight" do
|
|
109
|
+
media_type = subject.new('text', 'html', q: 0.9, extensions: { 'level' => '1' })
|
|
110
|
+
|
|
111
|
+
media_type.content_type.must_equal 'text/html;level=1'
|
|
112
|
+
media_type.to_s.must_equal 'text/html;level=1;q=0.9'
|
|
113
|
+
media_type.media_range.must_equal 'text/html'
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "does not add a quality parameter when there are no media parameters" do
|
|
117
|
+
subject.new('application', 'json').content_type.must_equal 'application/json'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "uses the same parameter quoting as the Accept representation" do
|
|
121
|
+
{
|
|
122
|
+
'a,b' => '"a,b"',
|
|
123
|
+
'a;b' => '"a;b"',
|
|
124
|
+
'a"b' => '"a\\"b"',
|
|
125
|
+
'a\\b' => '"a\\\\b"',
|
|
126
|
+
'' => '""',
|
|
127
|
+
"caf\u00e9" => "\"caf\u00e9\"",
|
|
128
|
+
"\x80\xFF".b => "\"\x80\xFF\"".b
|
|
129
|
+
}.each do |parameter, encoded|
|
|
130
|
+
media_type = subject.new('text', 'plain', extensions: { 'note' => parameter })
|
|
131
|
+
|
|
132
|
+
media_type.content_type.must_equal "text/plain;note=#{encoded}"
|
|
133
|
+
media_type.to_s.must_equal "text/plain;note=#{encoded};q=1"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "does not mutate the media type or parameter values" do
|
|
138
|
+
parameter = 'a,b;c"d\\e'.freeze
|
|
139
|
+
extensions = { 'note' => parameter }.freeze
|
|
140
|
+
media_type = subject.new('text', 'plain', q: 0.8, extensions: extensions)
|
|
141
|
+
original = media_type.to_h
|
|
142
|
+
result = media_type.content_type
|
|
143
|
+
result.replace('application/json')
|
|
144
|
+
|
|
145
|
+
media_type.to_h.must_equal original
|
|
146
|
+
media_type.extensions.must_equal extensions
|
|
147
|
+
media_type.content_type.must_equal 'text/plain;note="a,b;c\\"d\\\\e"'
|
|
148
|
+
media_type.to_s.must_equal 'text/plain;note="a,b;c\\"d\\\\e";q=0.8'
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
describe "#match" do
|
|
153
|
+
it "preserves raw comparison for hand-built non-String parameter values" do
|
|
154
|
+
[2, :public, nil].each do |value|
|
|
155
|
+
range = subject.new('application', 'json', extensions: { 'version' => value })
|
|
156
|
+
excluded = subject.new('application', 'json', q: 0, extensions: { 'version' => value })
|
|
157
|
+
|
|
158
|
+
['application/json;version=2', 'application/json;version=public'].each do |candidate|
|
|
159
|
+
range.match(candidate).must_equal false
|
|
160
|
+
range.accept?(candidate).must_equal false
|
|
161
|
+
excluded.reject?(candidate).must_equal false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
expected = value.nil?
|
|
165
|
+
range.match('application/json').must_equal expected
|
|
166
|
+
range.accept?('application/json').must_equal expected
|
|
167
|
+
excluded.reject?('application/json').must_equal expected
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it "folds only ASCII case in charset values" do
|
|
172
|
+
{ 'UTF-8' => 'utf-8', 'É' => 'é', 'K' => 'k' }.each do |value, candidate|
|
|
173
|
+
range = subject.new('text', 'plain', extensions: { 'charset' => value })
|
|
174
|
+
excluded = subject.new('text', 'plain', q: 0, extensions: { 'charset' => value })
|
|
175
|
+
header = "text/plain;charset=\"#{candidate}\""
|
|
176
|
+
expected = value == 'UTF-8'
|
|
177
|
+
|
|
178
|
+
range.match(header).must_equal expected
|
|
179
|
+
range.accept?(header).must_equal expected
|
|
180
|
+
excluded.reject?(header).must_equal expected
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
107
185
|
describe "#accept?" do
|
|
108
186
|
it "matches type and subtype without case sensitivity" do
|
|
109
187
|
subject.new('text', 'html').accept?('TEXT/HTML').must_equal true
|
data/spec/readme_spec.rb
CHANGED
|
@@ -36,7 +36,7 @@ class ReadmeSpec < Minitest::Test
|
|
|
36
36
|
|
|
37
37
|
response = request.get('/', 'HTTP_ACCEPT' => 'application/json', 'HTTP_ACCEPT_LANGUAGE' => 'en-GB')
|
|
38
38
|
assert_equal 200, response.status
|
|
39
|
-
assert_equal 'application/json', response['content-type']
|
|
39
|
+
assert_equal 'application/json;version=2', response['content-type']
|
|
40
40
|
assert_equal 'en-gb', response['content-language']
|
|
41
41
|
assert_equal '{"message":"Hello"}', response.body
|
|
42
42
|
assert_nil response['content-encoding']
|
|
@@ -44,6 +44,7 @@ class ReadmeSpec < Minitest::Test
|
|
|
44
44
|
|
|
45
45
|
[
|
|
46
46
|
{ 'HTTP_ACCEPT' => 'application/xml' },
|
|
47
|
+
{ 'HTTP_ACCEPT' => 'application/json;version=1' },
|
|
47
48
|
{ 'HTTP_ACCEPT_ENCODING' => 'identity;q=0' },
|
|
48
49
|
{ 'HTTP_ACCEPT_LANGUAGE' => 'fr' }
|
|
49
50
|
].each do |headers|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
require_relative "spec_helper"
|
|
2
|
+
require "open3"
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
describe "supported representation validation" do
|
|
6
|
+
[
|
|
7
|
+
[AcceptHeaders::MediaType::Negotiator, 'text/plain', '*/*', 'Accept: text/plain'],
|
|
8
|
+
[AcceptHeaders::Encoding::Negotiator, 'gzip', '*', 'Accept-Encoding: gzip'],
|
|
9
|
+
[AcceptHeaders::Language::Negotiator, 'en-US', '*', 'Accept-Language: en-US']
|
|
10
|
+
].each do |negotiator_class, valid, wildcard, prefixed|
|
|
11
|
+
describe negotiator_class.name do
|
|
12
|
+
it "rejects invalid offers before considering any header or other offer" do
|
|
13
|
+
invalid = [nil, false, 42, Object.new, '', ' ', wildcard, prefixed,
|
|
14
|
+
"#{valid},#{valid}", "#{valid};q=1", "#{valid};Q=1",
|
|
15
|
+
"#{valid};q=0.5", "#{valid};q=0", "#{valid}\r\n",
|
|
16
|
+
"#{valid}\0", "#{valid}\x7f", "#{valid}\v",
|
|
17
|
+
"\xff".b.force_encoding('UTF-8'), valid.encode('UTF-16LE')]
|
|
18
|
+
[nil, '', "#{wildcard};q=0", wildcard].each do |header|
|
|
19
|
+
negotiator = negotiator_class.new(header)
|
|
20
|
+
invalid.each do |value|
|
|
21
|
+
[:negotiate, :accept?].each do |method|
|
|
22
|
+
assert_raises(AcceptHeaders::InvalidSupportedValue, "#{method}: #{value.inspect}") do
|
|
23
|
+
negotiator.public_send(method, [valid, value])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "raises ArgumentError for an invalid standalone offer" do
|
|
31
|
+
[false, {}, Object.new].each do |offer|
|
|
32
|
+
assert_raises(AcceptHeaders::InvalidSupportedValue) do
|
|
33
|
+
negotiator_class.new(wildcard).negotiate(offer)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "retains nil and empty arrays as no offers" do
|
|
39
|
+
[nil, []].each do |supported|
|
|
40
|
+
negotiator = negotiator_class.new(nil)
|
|
41
|
+
assert_nil negotiator.negotiate(supported)
|
|
42
|
+
assert_equal false, negotiator.accept?(supported)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it "rejects objects that convert themselves to offer arrays" do
|
|
47
|
+
supported = Object.new
|
|
48
|
+
supported.define_singleton_method(:to_a) { [valid] }
|
|
49
|
+
assert_raises(AcceptHeaders::InvalidSupportedValue) do
|
|
50
|
+
negotiator_class.new(nil).negotiate(supported)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "accepts case and optional whitespace without mutating frozen offers" do
|
|
55
|
+
offer = " \t#{valid.upcase}\t ".freeze
|
|
56
|
+
negotiator = negotiator_class.new(wildcard)
|
|
57
|
+
assert_equal true, negotiator.accept?(offer)
|
|
58
|
+
assert_equal " \t#{valid.upcase}\t ", offer
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe "media type syntax" do
|
|
64
|
+
let(:negotiator) { AcceptHeaders::MediaType::Negotiator.new('*/*') }
|
|
65
|
+
|
|
66
|
+
it "rejects incomplete types and malformed parameters throughout the offer" do
|
|
67
|
+
['text', 'text/*', '*/plain', 'text/plain/', 'text /plain',
|
|
68
|
+
'text/plain;note', 'text/plain;note=', 'text/plain;=value',
|
|
69
|
+
'text/plain;note=two words', 'text/plain;note="unclosed',
|
|
70
|
+
'text/plain;note="closed"junk', 'text/plain;note="trailing' + "\\",
|
|
71
|
+
'text/plain;note="valid";broken', 'text/plain; Q = "1"',
|
|
72
|
+
'text/plain;note="a,b" , application/json', 'text/plain,garbage',
|
|
73
|
+
"text/plain;note=\"line\nbreak\"", "text/plain;note=\"escaped\\\nbreak\""].each do |offer|
|
|
74
|
+
assert_raises(AcceptHeaders::InvalidSupportedValue, offer) { negotiator.negotiate(offer) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it "preserves quoted delimiters, escapes, quality text, and non-ASCII values" do
|
|
79
|
+
offer = 'Text/Plain;note="a,b;c\\"d\\\\e;q=1";label="café"'.freeze
|
|
80
|
+
result = negotiator.negotiate(offer)
|
|
81
|
+
assert_equal 'text/plain', result.media_range
|
|
82
|
+
assert_equal({'note' => 'a,b;c"d\\e;q=1', 'label' => "caf\u00e9"}, result.extensions)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "preserves quoted obs-text bytes" do
|
|
86
|
+
offer = "text/plain;note=\"\x80\xff\"".b.freeze
|
|
87
|
+
result = negotiator.negotiate(offer)
|
|
88
|
+
assert_equal "\x80\xff".b, result.extensions['note']
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "uses HTTP bytes for multibyte offers, quoted pairs, and response escaping" do
|
|
92
|
+
{
|
|
93
|
+
[0x95, 0x5c, 0x5c] => [0x95, 0x5c],
|
|
94
|
+
[0x95, 0x5c, 0x5c, 0x61] => [0x95, 0x5c, 0x61],
|
|
95
|
+
[0x95, 0x5c, 0x61] => [0x95, 0x61]
|
|
96
|
+
}.each do |bytes, expected|
|
|
97
|
+
wire = 'text/plain;note="'.b + bytes.pack('C*') + '"'
|
|
98
|
+
offer = wire.dup.force_encoding('Shift_JIS').freeze
|
|
99
|
+
original = offer.dup
|
|
100
|
+
[nil, '*/*', wire, offer].each do |header|
|
|
101
|
+
matcher = AcceptHeaders::MediaType::Negotiator.new(header)
|
|
102
|
+
assert matcher.accept?(offer)
|
|
103
|
+
result = matcher.negotiate(offer)
|
|
104
|
+
assert_equal expected, result.extensions['note'].bytes
|
|
105
|
+
binary_result = negotiator.negotiate(wire)
|
|
106
|
+
assert_equal binary_result.content_type.bytes, result.content_type.bytes
|
|
107
|
+
assert_equal binary_result.to_s.bytes, result.to_s.bytes
|
|
108
|
+
[result.content_type, result.to_s].each do |serialized|
|
|
109
|
+
reparsed = AcceptHeaders::MediaType::Negotiator.new(serialized.b).list.first
|
|
110
|
+
assert_equal expected, reparsed.extensions['note'].bytes
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
assert_equal original, offer
|
|
114
|
+
assert_equal original.encoding, offer.encoding
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "compares parameter bytes across encodings without transcoding or Unicode case folding" do
|
|
119
|
+
offer = 'text/plain;note="café";charset="K"'.freeze
|
|
120
|
+
labelled_offer = offer.dup.force_encoding('ISO-8859-1').freeze
|
|
121
|
+
[offer, offer.b, labelled_offer].each do |header|
|
|
122
|
+
matcher = AcceptHeaders::MediaType::Negotiator.new(header)
|
|
123
|
+
[offer, offer.b, labelled_offer].each do |supported|
|
|
124
|
+
result = matcher.negotiate(supported)
|
|
125
|
+
assert_equal offer.bytes, result.content_type.bytes
|
|
126
|
+
end
|
|
127
|
+
refute matcher.accept?('text/plain;note="café";charset=k')
|
|
128
|
+
refute matcher.accept?(offer.encode('Windows-1252', undef: :replace))
|
|
129
|
+
end
|
|
130
|
+
result = negotiator.negotiate(offer)
|
|
131
|
+
assert_equal Encoding::UTF_8, result.extensions['note'].encoding
|
|
132
|
+
assert_equal Encoding::UTF_8, result.content_type.encoding
|
|
133
|
+
|
|
134
|
+
utf8_offer = 'text/plain;note="café"'.freeze
|
|
135
|
+
latin1_offer = utf8_offer.encode('ISO-8859-1').freeze
|
|
136
|
+
[utf8_offer, latin1_offer].each do |header|
|
|
137
|
+
matcher = AcceptHeaders::MediaType::Negotiator.new(header)
|
|
138
|
+
other_offer = header.equal?(utf8_offer) ? latin1_offer : utf8_offer
|
|
139
|
+
assert_nil matcher.negotiate(other_offer)
|
|
140
|
+
refute matcher.accept?(other_offer)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "accepts empty parameter segments and empty quoted values" do
|
|
145
|
+
result = negotiator.negotiate('text/plain; ; note="";;')
|
|
146
|
+
assert_equal({'note' => ''}, result.extensions)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "exposes the shared exception through each direct negotiator require" do
|
|
151
|
+
%w[media_type encoding language].each do |kind|
|
|
152
|
+
script = "require 'accept_headers/#{kind}/negotiator'; " \
|
|
153
|
+
"abort unless AcceptHeaders::InvalidSupportedValue < ArgumentError"
|
|
154
|
+
output, status = Open3.capture2e(RbConfig.ruby, '-Ilib', '-e', script)
|
|
155
|
+
assert status.success?, output
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: accept_headers
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jack Chu
|
|
@@ -87,6 +87,7 @@ files:
|
|
|
87
87
|
- lib/accept_headers/acceptable.rb
|
|
88
88
|
- lib/accept_headers/encoding.rb
|
|
89
89
|
- lib/accept_headers/encoding/negotiator.rb
|
|
90
|
+
- lib/accept_headers/invalid_supported_value.rb
|
|
90
91
|
- lib/accept_headers/language.rb
|
|
91
92
|
- lib/accept_headers/language/negotiator.rb
|
|
92
93
|
- lib/accept_headers/media_type.rb
|
|
@@ -115,6 +116,7 @@ files:
|
|
|
115
116
|
- spec/support/media_types/multipart.csv
|
|
116
117
|
- spec/support/media_types/text.csv
|
|
117
118
|
- spec/support/media_types/video.csv
|
|
119
|
+
- spec/supported_values_spec.rb
|
|
118
120
|
homepage: ''
|
|
119
121
|
licenses:
|
|
120
122
|
- MIT
|
|
@@ -159,3 +161,4 @@ test_files:
|
|
|
159
161
|
- spec/support/media_types/multipart.csv
|
|
160
162
|
- spec/support/media_types/text.csv
|
|
161
163
|
- spec/support/media_types/video.csv
|
|
164
|
+
- spec/supported_values_spec.rb
|