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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3e0dda15c77d1bf57df59c16d27f829e3fadf8868f63717e59bd65247b0ec3f
4
- data.tar.gz: 0baaeba925e9b4582f43fd3f699ae03debd44a8bb7d31b4b4501f178984ccb2e
3
+ metadata.gz: a7e488e8a3433dba0966e71c79d189240b68ae10eca39fa21e62e3a4924fcdb9
4
+ data.tar.gz: cf0955ec1218a983b6b3f79b6ee64255b79094fc6347e07798d20768a9a8ddb9
5
5
  SHA512:
6
- metadata.gz: b76e1a583842a4952c99b953fa406edc682bd4f5e30eb07f383f07f42cb89b71f4d7b6e31f4010ad1f375406b82ac65b2462b2672529a689e18fd31ed562c9df
7
- data.tar.gz: c4920549cbed068d58abec12c24058ea3d8cc8b6c4d6768374f698d7e4f544f4c03c4d831e67b83228b237e0136d384dc9877b9b4c772fa3ce1a48c9419299e1
6
+ metadata.gz: 56a57c4fc145a17435e6fcd0dfb90c074106161a6fdd7169285b6d9efec29ace5dcd9836434778a546cfe6d698148e3920603fc513b791ef78f8f4954e2dbf0d
7
+ data.tar.gz: 22f172cc9de919f571279e971b8f3fa740cd964323c4f09068e186a063c32ec898df1f6a5715e31039070eb94cf39e29b22686768208df2519832cdb32547431
data/CHANGELOG.md CHANGED
@@ -1,4 +1,14 @@
1
- ## 0.2.0 / Unreleased
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.2.0. See [Upgrading from 0.1.0 to 0.2.0](#upgrading-from-010-to-020) before updating an existing installation.
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.2.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 `#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.
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.media_range,
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 = [*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')]
@@ -0,0 +1,3 @@
1
+ module AcceptHeaders
2
+ class InvalidSupportedValue < ArgumentError; end
3
+ end
@@ -21,6 +21,10 @@ module AcceptHeaders
21
21
  end
22
22
 
23
23
  private
24
+ def valid_supported_value?(value)
25
+ SUPPORTED_LANGUAGE_TAG_PATTERN.match(value.b.strip)
26
+ end
27
+
24
28
  def no_header
25
29
  [Language.new]
26
30
  end
@@ -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.concat(";q=#{qvalue}")
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
- candidates = [*supported].each_with_index.map do |support, support_index|
18
- matches = list.each_with_index.select { |part, _| part.match(support) }
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)
@@ -1,3 +1,3 @@
1
1
  module AcceptHeaders
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -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).must_be_nil
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', nil].each do |support|
99
- subject.new('*').accept?(support).must_equal false
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
@@ -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
@@ -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.2.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