accept_headers 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +8 -0
- data/.github/workflows/ci.yml +23 -0
- data/CHANGELOG.md +13 -1
- data/Gemfile +2 -1
- data/README.md +115 -155
- data/accept_headers.gemspec +2 -2
- data/lib/accept_headers/acceptable.rb +8 -0
- data/lib/accept_headers/encoding/negotiator.rb +18 -8
- data/lib/accept_headers/encoding.rb +5 -3
- data/lib/accept_headers/language/negotiator.rb +3 -2
- data/lib/accept_headers/language.rb +11 -28
- data/lib/accept_headers/media_type/negotiator.rb +18 -19
- data/lib/accept_headers/media_type.rb +28 -15
- data/lib/accept_headers/negotiatable.rb +48 -28
- data/lib/accept_headers/version.rb +1 -1
- data/spec/encoding/negotiator_spec.rb +60 -6
- data/spec/encoding_spec.rb +8 -0
- data/spec/equality_spec.rb +70 -0
- data/spec/language/negotiator_spec.rb +72 -50
- data/spec/language_spec.rb +14 -4
- data/spec/malformed_spec.rb +121 -0
- data/spec/media_type/negotiator_spec.rb +97 -19
- data/spec/media_type_spec.rb +39 -3
- data/spec/middleware_spec.rb +5 -4
- data/spec/readme_spec.rb +66 -0
- data/spec/rejection_spec.rb +62 -0
- data/spec/spec_helper.rb +1 -3
- metadata +17 -11
- data/.travis.yml +0 -14
|
@@ -6,20 +6,14 @@ module AcceptHeaders
|
|
|
6
6
|
class Negotiator
|
|
7
7
|
include Negotiatable
|
|
8
8
|
|
|
9
|
-
MEDIA_TYPE_PATTERN =
|
|
10
|
-
PARAMS_PATTERN =
|
|
9
|
+
MEDIA_TYPE_PATTERN = /\A[ \t]*(?<type>[!#$%&'*+\-.^_`|~0-9A-Za-z]+)(?:\/(?<subtype>[!#$%&'*+\-.^_`|~0-9A-Za-z]+))?[ \t]*\z/
|
|
10
|
+
PARAMS_PATTERN = /\A\s*(?<attribute>[!#$%&'*+\-.^_`|~0-9A-Za-z]+)\s*=\s*(?<value>[!#$%&'*+\-.^_`|~0-9A-Za-z]+|"(?:[\t !#-\[\]-~\x80-\xFF]|\\[\t -~\x80-\xFF])*")\s*\z/n
|
|
11
11
|
HEADER_PREFIX = 'Accept:'
|
|
12
12
|
|
|
13
13
|
def negotiate(supported)
|
|
14
|
-
support,
|
|
15
|
-
return nil if support.nil?
|
|
16
|
-
|
|
17
|
-
media_type = parse(support).first
|
|
18
|
-
media_type.extensions = match.extensions
|
|
19
|
-
return media_type
|
|
20
|
-
rescue MediaType::Error
|
|
21
|
-
return nil
|
|
22
|
-
end
|
|
14
|
+
support, = super(supported)
|
|
15
|
+
return nil if support.nil?
|
|
16
|
+
parse(support).first
|
|
23
17
|
end
|
|
24
18
|
|
|
25
19
|
private
|
|
@@ -34,6 +28,8 @@ module AcceptHeaders
|
|
|
34
28
|
raise Error if accept_media_range.nil?
|
|
35
29
|
media_range = MEDIA_TYPE_PATTERN.match(accept_media_range)
|
|
36
30
|
raise Error if media_range.nil?
|
|
31
|
+
raise Error if media_range[:subtype].nil? && media_range[:type] != '*'
|
|
32
|
+
raise Error if media_range[:type] == '*' && ![nil, '*'].include?(media_range[:subtype])
|
|
37
33
|
MediaType.new(
|
|
38
34
|
media_range[:type],
|
|
39
35
|
media_range[:subtype],
|
|
@@ -44,16 +40,19 @@ module AcceptHeaders
|
|
|
44
40
|
|
|
45
41
|
def parse_extensions(extensions_string)
|
|
46
42
|
return {} if !extensions_string || extensions_string.empty?
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
extensions = {}
|
|
44
|
+
split_quoted(extensions_string, ';').each do |part|
|
|
45
|
+
next if part.strip.empty?
|
|
46
|
+
name = part.split('=', 2).first.strip.downcase
|
|
47
|
+
next if name == 'q'
|
|
48
|
+
param = PARAMS_PATTERN.match(part.b)
|
|
49
|
+
raise ParseError unless param
|
|
50
|
+
value = param[:value].force_encoding(part.encoding)
|
|
51
|
+
if value.start_with?('"')
|
|
52
|
+
value = value[1...-1].gsub(/\\(.)/m, '\\1')
|
|
54
53
|
end
|
|
54
|
+
extensions[param[:attribute].downcase] = value
|
|
55
55
|
end
|
|
56
|
-
extensions.delete('q')
|
|
57
56
|
extensions
|
|
58
57
|
end
|
|
59
58
|
end
|
|
@@ -47,7 +47,7 @@ module AcceptHeaders
|
|
|
47
47
|
def extensions=(hash)
|
|
48
48
|
@extensions = {}
|
|
49
49
|
hash.each do |k,v|
|
|
50
|
-
@extensions[k.strip] = v
|
|
50
|
+
@extensions[k.strip.downcase] = v
|
|
51
51
|
end
|
|
52
52
|
@extensions
|
|
53
53
|
end
|
|
@@ -63,29 +63,42 @@ module AcceptHeaders
|
|
|
63
63
|
|
|
64
64
|
def to_s
|
|
65
65
|
qvalue = (q == 0 || q == 1) ? q.to_i : q
|
|
66
|
-
string =
|
|
66
|
+
string = media_range
|
|
67
67
|
if extensions.size > 0
|
|
68
|
-
extensions.each
|
|
68
|
+
extensions.each do |key, value|
|
|
69
|
+
value = value.to_s
|
|
70
|
+
unless /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.match(value)
|
|
71
|
+
value = '"' + value.gsub(/["\\]/) { |character| "\\#{character}" } + '"'
|
|
72
|
+
end
|
|
73
|
+
string.concat(";#{key}=#{value}")
|
|
74
|
+
end
|
|
69
75
|
end
|
|
70
|
-
string
|
|
76
|
+
string.concat(";q=#{qvalue}")
|
|
71
77
|
end
|
|
72
78
|
|
|
73
79
|
def media_range
|
|
74
80
|
"#{type}/#{subtype}"
|
|
75
81
|
end
|
|
76
82
|
|
|
83
|
+
def specificity
|
|
84
|
+
[type == '*' ? 0 : 1, subtype == '*' ? 0 : 1, extensions.size]
|
|
85
|
+
end
|
|
86
|
+
|
|
77
87
|
def match(media_range_string)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
return false if media_range_string.nil? || media_range_string.strip.empty?
|
|
89
|
+
candidates = Negotiator.new(media_range_string).list
|
|
90
|
+
return false unless candidates.size == 1
|
|
91
|
+
candidate = candidates.first
|
|
92
|
+
return false unless (type == '*' || type == candidate.type) &&
|
|
93
|
+
(subtype == '*' || subtype == candidate.subtype)
|
|
94
|
+
|
|
95
|
+
extensions.all? do |name, value|
|
|
96
|
+
supported_value = candidate.extensions[name]
|
|
97
|
+
if name == 'charset'
|
|
98
|
+
supported_value && value.casecmp?(supported_value)
|
|
99
|
+
else
|
|
100
|
+
value == supported_value
|
|
101
|
+
end
|
|
89
102
|
end
|
|
90
103
|
end
|
|
91
104
|
end
|
|
@@ -5,29 +5,27 @@ module AcceptHeaders
|
|
|
5
5
|
|
|
6
6
|
attr_reader :list
|
|
7
7
|
|
|
8
|
-
Q_PATTERN =
|
|
8
|
+
Q_PATTERN = /\A[ \t]*q[ \t]*=[ \t]*(?<q>.*?)[ \t]*\z/im
|
|
9
|
+
QVALUE_PATTERN = /\A(?:0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?)\z/
|
|
9
10
|
|
|
10
11
|
def initialize(header)
|
|
12
|
+
header = header.b if header && !header.valid_encoding?
|
|
11
13
|
@list = parse(header)
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def negotiate(supported)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
(rejects + acceptable).each do |part|
|
|
20
|
-
supported.each do |support|
|
|
21
|
-
if part.match(support)
|
|
22
|
-
if part.q == 0.0
|
|
23
|
-
next
|
|
24
|
-
else
|
|
25
|
-
return [support, part]
|
|
26
|
-
end
|
|
27
|
-
end
|
|
17
|
+
candidates = [*supported].each_with_index.map do |support, support_index|
|
|
18
|
+
matches = list.each_with_index.select { |part, _| part.match(support) }
|
|
19
|
+
part, header_index = matches.max_by do |match, _|
|
|
20
|
+
[match.specificity, match.q.zero? ? 1 : 0, match.q]
|
|
28
21
|
end
|
|
22
|
+
next if part.nil? || part.q.zero?
|
|
23
|
+
[support, part, header_index, support_index]
|
|
24
|
+
end.compact
|
|
25
|
+
best = candidates.max_by do |_, part, header_index, support_index|
|
|
26
|
+
[part.q, part.specificity, -header_index, -support_index]
|
|
29
27
|
end
|
|
30
|
-
|
|
28
|
+
best && best.first(2)
|
|
31
29
|
end
|
|
32
30
|
|
|
33
31
|
def accept?(other)
|
|
@@ -50,11 +48,11 @@ module AcceptHeaders
|
|
|
50
48
|
def parse(header, &block)
|
|
51
49
|
return no_header unless header
|
|
52
50
|
header = header.dup
|
|
53
|
-
header.sub!(/\A#{self.class::HEADER_PREFIX}\s
|
|
51
|
+
header.sub!(/\A#{self.class::HEADER_PREFIX}\s*/i, '')
|
|
54
52
|
header.strip!
|
|
55
|
-
return
|
|
53
|
+
return [] if header.empty?
|
|
56
54
|
list = []
|
|
57
|
-
header
|
|
55
|
+
split_quoted(header, ',').each do |entry|
|
|
58
56
|
begin
|
|
59
57
|
item = parse_item(entry)
|
|
60
58
|
next if item.nil?
|
|
@@ -64,20 +62,42 @@ module AcceptHeaders
|
|
|
64
62
|
end
|
|
65
63
|
end
|
|
66
64
|
list.sort! { |x,y| y <=> x }
|
|
65
|
+
rescue ParseError
|
|
66
|
+
[]
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
69
|
+
def split_quoted(value, delimiter)
|
|
70
|
+
parts = []
|
|
71
|
+
part = +""
|
|
72
|
+
quoted = false
|
|
73
|
+
escaped = false
|
|
74
|
+
value.each_char do |character|
|
|
75
|
+
if escaped
|
|
76
|
+
escaped = false
|
|
77
|
+
elsif quoted && character == "\\"
|
|
78
|
+
escaped = true
|
|
79
|
+
elsif character == '"'
|
|
80
|
+
quoted = !quoted
|
|
81
|
+
elsif !quoted && character == delimiter
|
|
82
|
+
parts << part
|
|
83
|
+
part = +""
|
|
84
|
+
next
|
|
78
85
|
end
|
|
86
|
+
part << character
|
|
87
|
+
end
|
|
88
|
+
raise ParseError if quoted || escaped
|
|
89
|
+
parts << part
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_q(header)
|
|
93
|
+
return 1 unless header
|
|
94
|
+
split_quoted(header, ';').each do |parameter|
|
|
95
|
+
q_match = Q_PATTERN.match(parameter)
|
|
96
|
+
next unless q_match
|
|
97
|
+
value = q_match[:q]
|
|
98
|
+
return QVALUE_PATTERN.match(value) ? value.to_f : 0.001
|
|
79
99
|
end
|
|
80
|
-
|
|
100
|
+
1
|
|
81
101
|
end
|
|
82
102
|
end
|
|
83
103
|
end
|
|
@@ -25,7 +25,7 @@ describe AcceptHeaders::Encoding::Negotiator do
|
|
|
25
25
|
]
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
it "
|
|
28
|
+
it "parses content codings in the bundled IANA snapshot" do
|
|
29
29
|
require 'csv'
|
|
30
30
|
# https://www.iana.org/assignments/http-parameters/http-parameters.xml#content-coding
|
|
31
31
|
CSV.foreach("spec/support/encodings/content-coding.csv", headers: true) do |row|
|
|
@@ -38,10 +38,14 @@ describe AcceptHeaders::Encoding::Negotiator do
|
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
it "
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
it "allows only identity when the accept-encoding header is empty" do
|
|
42
|
+
['', " \t", 'Accept-Encoding:', 'accept-encoding: '].each do |header|
|
|
43
|
+
subject.new(header).list.map(&:encoding).must_equal ['identity']
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "allows any content coding when the accept-encoding header is absent" do
|
|
48
|
+
subject.new(nil).list.map(&:encoding).must_equal ['*']
|
|
45
49
|
end
|
|
46
50
|
|
|
47
51
|
it "defaults q to 1 if it's not explicitly specified" do
|
|
@@ -75,6 +79,10 @@ describe AcceptHeaders::Encoding::Negotiator do
|
|
|
75
79
|
encoding.new('gzip', q: 1.0)
|
|
76
80
|
]
|
|
77
81
|
end
|
|
82
|
+
|
|
83
|
+
it "parses HTTP token characters and skips delimiter-containing coding names" do
|
|
84
|
+
subject.new('example.coding, bad{coding}, bad\\coding').list.map(&:encoding).must_equal ['example.coding']
|
|
85
|
+
end
|
|
78
86
|
end
|
|
79
87
|
|
|
80
88
|
describe "#to_s" do
|
|
@@ -86,7 +94,53 @@ describe AcceptHeaders::Encoding::Negotiator do
|
|
|
86
94
|
describe "negotiate supported encodings" do
|
|
87
95
|
it "returns a best matching encoding" do
|
|
88
96
|
n = subject.new('deflate; q=0.5, gzip, compress; q=0.8, identity')
|
|
89
|
-
n.negotiate(['identity', 'deflate']).must_equal
|
|
97
|
+
n.negotiate(['identity', 'deflate']).encoding.must_equal 'identity'
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it "distinguishes an absent header from an empty field value" do
|
|
101
|
+
subject.new(nil).negotiate('gzip').encoding.must_equal 'gzip'
|
|
102
|
+
['', " \t", 'Accept-Encoding:'].each do |header|
|
|
103
|
+
negotiator = subject.new(header)
|
|
104
|
+
negotiator.negotiate('gzip').must_be_nil
|
|
105
|
+
negotiator.negotiate(['gzip', 'identity']).encoding.must_equal 'identity'
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "prefers an acceptable coding over implicit identity" do
|
|
110
|
+
subject.new('gzip;q=0.5').negotiate(['identity', 'gzip']).encoding.must_equal 'gzip'
|
|
111
|
+
subject.new('*;q=0.5').negotiate(['identity', 'br']).encoding.must_equal 'br'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "falls back to identity without accepting unlisted content codings" do
|
|
115
|
+
negotiator = subject.new('gzip')
|
|
116
|
+
negotiator.negotiate(['br', 'identity']).encoding.must_equal 'identity'
|
|
117
|
+
negotiator.accept?('identity').must_equal true
|
|
118
|
+
negotiator.negotiate('br').must_be_nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "does not turn another coding's rejection into an identity rejection" do
|
|
122
|
+
negotiator = subject.new('gzip;q=0')
|
|
123
|
+
negotiator.negotiate(['gzip', 'identity']).encoding.must_equal 'identity'
|
|
124
|
+
negotiator.accept?('gzip').must_equal false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "honors explicit identity preferences and exclusions" do
|
|
128
|
+
subject.new('gzip;q=0.5, identity;q=0.8').negotiate(['gzip', 'identity']).encoding.must_equal 'identity'
|
|
129
|
+
subject.new('identity;q=0').negotiate('identity').must_be_nil
|
|
130
|
+
subject.new('*;q=0').negotiate('identity').must_be_nil
|
|
131
|
+
subject.new('*;q=0, identity;q=0.5').negotiate('identity').encoding.must_equal 'identity'
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "matches supported coding names without case sensitivity" do
|
|
135
|
+
subject.new('GZIP').negotiate('GZip').encoding.must_equal 'gzip'
|
|
136
|
+
subject.new('br').negotiate('IDENTITY').encoding.must_equal 'identity'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "requires supported names to be complete HTTP tokens" do
|
|
140
|
+
negotiator = subject.new('*')
|
|
141
|
+
['gzip\\bad', 'gzip{bad}', "gzip\nidentity", 'gzip,br'].each do |coding|
|
|
142
|
+
negotiator.negotiate(coding).must_be_nil
|
|
143
|
+
end
|
|
90
144
|
end
|
|
91
145
|
end
|
|
92
146
|
end
|
data/spec/encoding_spec.rb
CHANGED
|
@@ -74,6 +74,14 @@ describe AcceptHeaders::Encoding do
|
|
|
74
74
|
a.accept?('gzip').must_equal true
|
|
75
75
|
b = subject.new('gzip', q: 0.001)
|
|
76
76
|
b.accept?('gzip').must_equal true
|
|
77
|
+
a.accept?('GZIP').must_equal true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "matches identity only as its own coding or a wildcard" do
|
|
81
|
+
subject.new('gzip').accept?('identity').must_equal false
|
|
82
|
+
subject.new('gzip', q: 0).reject?('identity').must_equal false
|
|
83
|
+
subject.new('identity').accept?('IDENTITY').must_equal true
|
|
84
|
+
subject.new('*').accept?('identity').must_equal true
|
|
77
85
|
end
|
|
78
86
|
|
|
79
87
|
it "accepted if the encoding is *" do
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require_relative "spec_helper"
|
|
2
|
+
|
|
3
|
+
describe "acceptable value equality" do
|
|
4
|
+
let(:values) do
|
|
5
|
+
[
|
|
6
|
+
AcceptHeaders::MediaType.new('text', 'html'),
|
|
7
|
+
AcceptHeaders::Encoding.new('gzip'),
|
|
8
|
+
AcceptHeaders::Language.new('en', 'us')
|
|
9
|
+
]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "compares all normalized fields for values of the same class" do
|
|
13
|
+
assert_equal AcceptHeaders::MediaType.new('text', 'html', extensions: { 'level' => '1' }),
|
|
14
|
+
AcceptHeaders::MediaType.new('TEXT', 'HTML', extensions: { 'level' => '1' })
|
|
15
|
+
assert_equal AcceptHeaders::Encoding.new('gzip'), AcceptHeaders::Encoding.new('GZIP')
|
|
16
|
+
assert_equal AcceptHeaders::Language.new('en', 'us'), AcceptHeaders::Language.new('EN', 'US')
|
|
17
|
+
values.each { |value| assert_equal value, value }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it "distinguishes different media types and parameters at the same preference rank" do
|
|
21
|
+
refute_equal AcceptHeaders::MediaType.new('text', 'html'), AcceptHeaders::MediaType.new('text', 'plain')
|
|
22
|
+
refute_equal AcceptHeaders::MediaType.new('text', 'plain'), AcceptHeaders::MediaType.new('application', 'plain')
|
|
23
|
+
refute_equal AcceptHeaders::MediaType.new('text', 'html', extensions: { 'level' => '1' }),
|
|
24
|
+
AcceptHeaders::MediaType.new('text', 'html', extensions: { 'level' => '2' })
|
|
25
|
+
refute_equal AcceptHeaders::MediaType.new('text', 'html', extensions: { 'level' => '1' }),
|
|
26
|
+
AcceptHeaders::MediaType.new('text', 'html', extensions: { 'version' => '1' })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "distinguishes encodings and languages at the same preference rank" do
|
|
30
|
+
refute_equal AcceptHeaders::Encoding.new('gzip'), AcceptHeaders::Encoding.new('compress')
|
|
31
|
+
refute_equal AcceptHeaders::Language.new('en', 'us'), AcceptHeaders::Language.new('en', 'gb')
|
|
32
|
+
refute_equal AcceptHeaders::Language.new('en', 'us'), AcceptHeaders::Language.new('fr', 'us')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "includes quality in equality" do
|
|
36
|
+
values.each do |value|
|
|
37
|
+
different_quality = value.dup
|
|
38
|
+
different_quality.q = 0.5
|
|
39
|
+
refute_equal value, different_quality
|
|
40
|
+
refute_equal different_quality, value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "returns false symmetrically for different classes and unrelated objects" do
|
|
45
|
+
values.each do |value|
|
|
46
|
+
(values.reject { |other| other.class == value.class } + [nil, Object.new, value.to_h]).each do |other|
|
|
47
|
+
refute_equal value, other
|
|
48
|
+
refute_equal other, value
|
|
49
|
+
end
|
|
50
|
+
subclass_value = Class.new(value.class).new
|
|
51
|
+
value.to_h.each { |field, field_value| subclass_value.public_send("#{field}=", field_value) }
|
|
52
|
+
refute_equal value, subclass_value
|
|
53
|
+
refute_equal subclass_value, value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it "keeps equal preference ranks sortable without making their values equal" do
|
|
58
|
+
[
|
|
59
|
+
[AcceptHeaders::MediaType.new('text', 'html'), AcceptHeaders::MediaType.new('text', 'plain')],
|
|
60
|
+
[AcceptHeaders::Encoding.new('gzip'), AcceptHeaders::Encoding.new('compress')],
|
|
61
|
+
[AcceptHeaders::Language.new('en', 'us'), AcceptHeaders::Language.new('en', 'gb')]
|
|
62
|
+
].each do |left, right|
|
|
63
|
+
assert_equal 0, left <=> right
|
|
64
|
+
assert_equal 0, right <=> left
|
|
65
|
+
refute_equal left, right
|
|
66
|
+
refute_equal right, left
|
|
67
|
+
assert_equal [left.to_h, right.to_h], [left, right].sort.map(&:to_h)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -2,84 +2,106 @@ require_relative "../spec_helper"
|
|
|
2
2
|
|
|
3
3
|
describe AcceptHeaders::Language::Negotiator do
|
|
4
4
|
subject { AcceptHeaders::Language::Negotiator }
|
|
5
|
-
let(:language) { AcceptHeaders::Language }
|
|
6
5
|
|
|
7
|
-
describe "parsing an
|
|
8
|
-
it "
|
|
9
|
-
subject.new(
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
describe "parsing an Accept-Language header" do
|
|
7
|
+
it "sorts ranges by quality and specificity" do
|
|
8
|
+
subject.new('en;q=0.2, en-us').list.map(&:to_h).must_equal [
|
|
9
|
+
{primary_tag: 'en', subtag: 'us', q: 1.0},
|
|
10
|
+
{primary_tag: 'en', subtag: '*', q: 0.2}
|
|
12
11
|
]
|
|
13
12
|
|
|
14
|
-
subject.new(
|
|
15
|
-
language.new('en', 'us'),
|
|
16
|
-
language.new('en', '*'),
|
|
17
|
-
language.new('*', '*', q: 0.8)
|
|
18
|
-
]
|
|
13
|
+
subject.new('en, en-us, *;q=0.8').list.map(&:language_tag).must_equal ['en-us', 'en', '*']
|
|
19
14
|
end
|
|
20
15
|
|
|
21
|
-
it "ignores the
|
|
22
|
-
subject.new('Accept-Language: en-us').list.must_equal [
|
|
23
|
-
language.new('en', 'us')
|
|
24
|
-
]
|
|
16
|
+
it "ignores the Accept-Language prefix" do
|
|
17
|
+
subject.new('Accept-Language: en-us').list.map(&:language_tag).must_equal ['en-us']
|
|
25
18
|
end
|
|
26
19
|
|
|
27
|
-
it "
|
|
28
|
-
subject.new(
|
|
29
|
-
|
|
30
|
-
]
|
|
20
|
+
it "accepts any language when the header is missing" do
|
|
21
|
+
subject.new(nil).list.map(&:language_tag).must_equal ['*']
|
|
22
|
+
subject.new(nil).negotiate('en-US').language_tag.must_equal 'en-us'
|
|
31
23
|
end
|
|
32
24
|
|
|
33
|
-
it "
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
25
|
+
it "accepts no languages when the header is present and empty" do
|
|
26
|
+
['', " \t", 'Accept-Language:'].each do |header|
|
|
27
|
+
assert_empty subject.new(header).list
|
|
28
|
+
assert_nil subject.new(header).negotiate('en-US')
|
|
29
|
+
end
|
|
37
30
|
end
|
|
38
31
|
|
|
39
|
-
it "
|
|
40
|
-
subject.new(
|
|
41
|
-
|
|
32
|
+
it "parses a wildcard range" do
|
|
33
|
+
subject.new('*').list.map(&:to_h).must_equal [
|
|
34
|
+
{primary_tag: '*', subtag: '*', q: 1.0}
|
|
42
35
|
]
|
|
43
36
|
end
|
|
44
37
|
|
|
45
|
-
it "
|
|
46
|
-
subject.new(
|
|
47
|
-
language.new('en', 'us'),
|
|
48
|
-
language.new('en', 'gb')
|
|
49
|
-
]
|
|
38
|
+
it "defaults quality to one" do
|
|
39
|
+
subject.new('en-us').list.first.to_h.must_equal({primary_tag: 'en', subtag: 'us', q: 1.0})
|
|
50
40
|
end
|
|
51
41
|
|
|
52
|
-
it "
|
|
53
|
-
subject.new(
|
|
54
|
-
language.new('en', 'us'),
|
|
55
|
-
language.new('en', 'gb')
|
|
56
|
-
]
|
|
42
|
+
it "uses the documented fallback quality for an invalid weight" do
|
|
43
|
+
subject.new('en-us;q=x').list.first.to_h.must_equal({primary_tag: 'en', subtag: 'us', q: 0.001})
|
|
57
44
|
end
|
|
58
45
|
|
|
59
|
-
it "
|
|
60
|
-
subject.new("en-
|
|
61
|
-
language.new('en', 'plain', q: 0.001)
|
|
62
|
-
]
|
|
46
|
+
it "allows optional whitespace around ranges" do
|
|
47
|
+
subject.new("\ten-us , en-gb\t").list.map(&:language_tag).must_equal ['en-us', 'en-gb']
|
|
63
48
|
end
|
|
64
49
|
|
|
65
|
-
it "
|
|
66
|
-
subject.new(
|
|
67
|
-
|
|
50
|
+
it "retains every subtag and normalizes case" do
|
|
51
|
+
subject.new('ZH-Hant-TW, en-US-omg;q=0.9').list.map(&:to_h).must_equal [
|
|
52
|
+
{primary_tag: 'zh', subtag: 'hant-tw', q: 1.0},
|
|
53
|
+
{primary_tag: 'en', subtag: 'us-omg', q: 0.9}
|
|
68
54
|
]
|
|
69
55
|
end
|
|
56
|
+
|
|
57
|
+
it "skips ranges outside the basic language-range grammar" do
|
|
58
|
+
invalid = ['en-*', '*-US', 'en_US', '123', 'englishxx', 'en-123456789', 'en--US', 'en - US']
|
|
59
|
+
subject.new((invalid + ['fr']).join(',')).list.map(&:language_tag).must_equal ['fr']
|
|
60
|
+
end
|
|
70
61
|
end
|
|
71
62
|
|
|
72
63
|
describe "#to_s" do
|
|
73
|
-
it "
|
|
74
|
-
subject.new(
|
|
64
|
+
it "serializes basic language ranges without adding wildcards" do
|
|
65
|
+
subject.new('en, zh-Hant-TW;q=0.9').to_s.must_equal 'en;q=1,zh-hant-tw;q=0.9'
|
|
75
66
|
end
|
|
76
67
|
end
|
|
77
68
|
|
|
78
|
-
describe "
|
|
79
|
-
it "returns
|
|
80
|
-
match =
|
|
81
|
-
|
|
82
|
-
|
|
69
|
+
describe "negotiating supported languages" do
|
|
70
|
+
it "returns the supported language rather than the matching range" do
|
|
71
|
+
match = subject.new('en, *;q=0.8').negotiate('en-US')
|
|
72
|
+
match.to_h.must_equal({primary_tag: 'en', subtag: 'us', q: 1.0})
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "returns a plain supported language without a synthetic wildcard" do
|
|
76
|
+
subject.new('*').negotiate('en').language_tag.must_equal 'en'
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "matches script and region subtags case insensitively" do
|
|
80
|
+
subject.new('zh-Hant').negotiate(['zh-Hans-TW', 'ZH-hant-TW']).language_tag.must_equal 'zh-hant-tw'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it "applies a more specific exclusion before its parent language range" do
|
|
84
|
+
match = subject.new('zh, zh-Hant;q=0').negotiate(['zh-Hant-TW', 'zh-Hans-CN'])
|
|
85
|
+
match.language_tag.must_equal 'zh-hans-cn'
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "uses the most specific matching range quality" do
|
|
89
|
+
match = subject.new('en;q=1, en-US;q=0.1, fr;q=0.5').negotiate(['en-US', 'fr'])
|
|
90
|
+
match.language_tag.must_equal 'fr'
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "rejects candidates outside a subtag boundary" do
|
|
94
|
+
subject.new('en-US').accept?('en-USA').must_equal false
|
|
95
|
+
end
|
|
96
|
+
|
|
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
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "returns nil when no supported language matches" do
|
|
104
|
+
_(subject.new('fr').negotiate('en')).must_be_nil
|
|
83
105
|
end
|
|
84
106
|
end
|
|
85
107
|
end
|
data/spec/language_spec.rb
CHANGED
|
@@ -91,6 +91,11 @@ describe AcceptHeaders::Language do
|
|
|
91
91
|
subject.new('en', 'us', q: 0.9).language_tag.must_equal "en-us"
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
+
it "outputs the primary tag alone when no subtag is given" do
|
|
95
|
+
subject.new('en').language_tag.must_equal 'en'
|
|
96
|
+
subject.new('en', '*').language_tag.must_equal 'en'
|
|
97
|
+
end
|
|
98
|
+
|
|
94
99
|
it "if primary tag is * and subtag is * or nil, outputs *" do
|
|
95
100
|
subject.new('*', nil).language_tag.must_equal '*'
|
|
96
101
|
subject.new('*', '*').language_tag.must_equal '*'
|
|
@@ -119,6 +124,13 @@ describe AcceptHeaders::Language do
|
|
|
119
124
|
b.accept?('en-us').must_equal true
|
|
120
125
|
end
|
|
121
126
|
|
|
127
|
+
it "matches complete subtag prefixes case insensitively" do
|
|
128
|
+
a = subject.new('zh', 'hant')
|
|
129
|
+
a.accept?('ZH-Hant-TW').must_equal true
|
|
130
|
+
a.accept?('zh-Hans-TW').must_equal false
|
|
131
|
+
a.accept?('zh-Hantish').must_equal false
|
|
132
|
+
end
|
|
133
|
+
|
|
122
134
|
it "not accepted if the primary_tag and subtag don't match" do
|
|
123
135
|
a = subject.new('en', 'us')
|
|
124
136
|
a.accept?('en-gb').must_equal false
|
|
@@ -156,8 +168,7 @@ describe AcceptHeaders::Language do
|
|
|
156
168
|
a.accept?(nil).must_equal false
|
|
157
169
|
end
|
|
158
170
|
|
|
159
|
-
|
|
160
|
-
it "not accepted if..." do
|
|
171
|
+
it "does not accept a wildcard as a concrete supported tag" do
|
|
161
172
|
a = subject.new('en', 'us')
|
|
162
173
|
a.accept?('*').must_equal false
|
|
163
174
|
end
|
|
@@ -195,8 +206,7 @@ describe AcceptHeaders::Language do
|
|
|
195
206
|
a.reject?('en-gb').must_equal false
|
|
196
207
|
end
|
|
197
208
|
|
|
198
|
-
|
|
199
|
-
it "not rejected if..." do
|
|
209
|
+
it "does not reject a wildcard as a concrete supported tag" do
|
|
200
210
|
a = subject.new('en', 'us', q: 0)
|
|
201
211
|
a.reject?('*').must_equal false
|
|
202
212
|
end
|