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.
@@ -6,20 +6,14 @@ module AcceptHeaders
6
6
  class Negotiator
7
7
  include Negotiatable
8
8
 
9
- MEDIA_TYPE_PATTERN = /^\s*(?<type>[\w!#$%^&*\-\+{}\\|'.`~]+)(?:\s*\/\s*(?<subtype>[\w!#$%^&*\-\+{}\\|'.`~]+))?\s*$/
10
- PARAMS_PATTERN = /(?<attribute>[\w!#$%^&*\-\+{}\\|'.`~]+)\s*\=\s*(?:\"(?<value>[^"]*)\"|\'(?<value>[^']*)\'|(?<value>[\w!#$%^&*\-\+{}\\|\'.`~]*))/
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, match = super(supported)
15
- return nil if support.nil? && match.nil?
16
- begin
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
- if extensions_string.match(/['"]/)
48
- extensions = extensions_string.scan(PARAMS_PATTERN).map(&:compact).to_h
49
- else
50
- extensions = {}
51
- extensions_string.split(';').each do |part|
52
- param = PARAMS_PATTERN.match(part)
53
- extensions[param[:attribute]] = param[:value] if param
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 = "#{media_range};q=#{qvalue}"
66
+ string = media_range
67
67
  if extensions.size > 0
68
- extensions.each { |k, v| string.concat(";#{k}=#{v}") }
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
- match_data = Negotiator::MEDIA_TYPE_PATTERN.match(media_range_string)
79
- if !match_data
80
- false
81
- elsif type == match_data[:type] && subtype == match_data[:subtype]
82
- true
83
- elsif type == match_data[:type] && subtype == '*'
84
- true
85
- elsif type == '*' && subtype == '*'
86
- true
87
- else
88
- false
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 = /(?:\A|;)\s*(?<exists>qs*\=)\s*(?:(?<q>0\.\d{1,3}|[01])|(?:[^;]*))\s*(?:\z|;)/
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
- return nil if list.empty?
16
- supported = [*supported]
17
- # TODO: Maybe q=0 should be first by default when sorting
18
- rejects, acceptable = list.partition { |m| m.q == 0.0 }
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
- nil
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 no_header if header.empty?
53
+ return [] if header.empty?
56
54
  list = []
57
- header.split(',').each do |entry|
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 parse_q(header)
70
- q = 1
71
- return q unless header
72
- q_match = Q_PATTERN.match(header)
73
- if q_match && q_match[:exists]
74
- if q_match[:q]
75
- q = q_match[:q]
76
- else
77
- q = 0.001
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
- q
100
+ 1
81
101
  end
82
102
  end
83
103
  end
@@ -1,3 +1,3 @@
1
1
  module AcceptHeaders
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -25,7 +25,7 @@ describe AcceptHeaders::Encoding::Negotiator do
25
25
  ]
26
26
  end
27
27
 
28
- it "supports all registered IANA encodings" do
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 "sets encoding to * when the accept-encoding header is empty" do
42
- subject.new('').list.must_equal [
43
- encoding.new('*')
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 encoding.new('identity')
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
@@ -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 accept header" do
8
- it "returns a sorted array of media primary tags" do
9
- subject.new("en-*;q=0.2, en-us").list.must_equal [
10
- language.new('en', 'us'),
11
- language.new('en', '*', q: 0.2)
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("en-*, en-us, *;q=0.8").list.must_equal [
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 'Accept-Language:' prefix" do
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 "sets media primary tag to */* when the accept header is empty" do
28
- subject.new('').list.must_equal [
29
- language.new('*', '*')
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 "sets media primary tag to */* when the primary tag is only *" do
34
- subject.new('*').list.must_equal [
35
- language.new('*', '*')
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 "defaults q to 1 if it's not explicitly specified" do
40
- subject.new("en-us").list.must_equal [
41
- language.new('en', 'plain', q: 1.0)
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 "strips whitespace from between media primary tags" do
46
- subject.new("\ten-us\r,\nen-gb\s").list.must_equal [
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 "strips whitespace around q" do
53
- subject.new("en-us;\tq\r=\n1, en-gb").list.must_equal [
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 "has a q value of 0.001 when parsed q is invalid" do
60
- subject.new("en-us;q=x").list.must_equal [
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 "skips invalid media primary tags" do
66
- subject.new("en-us, en-us-omg;q=0.9").list.must_equal [
67
- language.new('en', 'us', q: 1)
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 "returns a string of each language #to_s joined by a comma" do
74
- subject.new("en-us, en-gb;q=0.9").to_s.must_equal "en-us;q=1,en-gb;q=0.9"
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 "negotiate supported languages" do
79
- it "returns a best matching language" do
80
- match = language.new('en', 'us')
81
- n = subject.new('en-*, en-us, *;q=0.8')
82
- n.negotiate('en-us').must_equal language.new('en', 'us')
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
@@ -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
- # TODO: test *
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
- # TODO: test *
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