accept_headers 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,17 +14,7 @@ module AcceptHeaders
14
14
  end
15
15
 
16
16
  def <=>(other)
17
- if q < other.q
18
- -1
19
- elsif q > other.q
20
- 1
21
- elsif (primary_tag == '*' && other.primary_tag != '*') || (subtag == '*' && other.subtag != '*')
22
- -1
23
- elsif (primary_tag != '*' && other.primary_tag == '*') || (subtag != '*' && other.subtag == '*')
24
- 1
25
- else
26
- 0
27
- end
17
+ [q, specificity] <=> [other.q, other.specificity]
28
18
  end
29
19
 
30
20
  def primary_tag=(value)
@@ -53,26 +43,19 @@ module AcceptHeaders
53
43
  end
54
44
 
55
45
  def language_tag
56
- if primary_tag == '*' && (subtag.nil? || subtag == '*')
57
- '*'
58
- else
59
- "#{primary_tag}-#{subtag}"
60
- end
46
+ subtag == '*' ? primary_tag : "#{primary_tag}-#{subtag}"
47
+ end
48
+
49
+ def specificity
50
+ [primary_tag == '*' ? 0 : language_tag.split('-').length]
61
51
  end
62
52
 
63
53
  def match(language_tag_string)
64
- match_data = Negotiator::LANGUAGE_TAG_PATTERN.match(language_tag_string)
65
- if !match_data
66
- false
67
- elsif primary_tag == match_data[:primary_tag] && subtag == match_data[:subtag]
68
- true
69
- elsif primary_tag == match_data[:primary_tag] && subtag == '*'
70
- true
71
- elsif primary_tag == '*'
72
- true
73
- else
74
- false
75
- end
54
+ return false unless language_tag_string.is_a?(String)
55
+ return false unless Negotiator::SUPPORTED_LANGUAGE_TAG_PATTERN.match?(language_tag_string)
56
+ candidate = language_tag_string.downcase
57
+ range = language_tag
58
+ range == '*' || candidate == range || candidate.start_with?("#{range}-")
76
59
  end
77
60
  end
78
61
  end
@@ -6,23 +6,31 @@ 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
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
+
26
34
  def no_header
27
35
  [MediaType.new]
28
36
  end
@@ -34,6 +42,8 @@ module AcceptHeaders
34
42
  raise Error if accept_media_range.nil?
35
43
  media_range = MEDIA_TYPE_PATTERN.match(accept_media_range)
36
44
  raise Error if media_range.nil?
45
+ raise Error if media_range[:subtype].nil? && media_range[:type] != '*'
46
+ raise Error if media_range[:type] == '*' && ![nil, '*'].include?(media_range[:subtype])
37
47
  MediaType.new(
38
48
  media_range[:type],
39
49
  media_range[:subtype],
@@ -44,16 +54,19 @@ module AcceptHeaders
44
54
 
45
55
  def parse_extensions(extensions_string)
46
56
  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
57
+ extensions = {}
58
+ split_quoted(extensions_string, ';').each do |part|
59
+ next if part.strip.empty?
60
+ name = part.split('=', 2).first.strip.downcase
61
+ next if name == 'q'
62
+ param = PARAMS_PATTERN.match(part.b)
63
+ raise ParseError unless param
64
+ value = param[:value].force_encoding(part.encoding)
65
+ if value.start_with?('"')
66
+ value = value.b[1...-1].gsub(/\\(.)/m, '\\1').force_encoding(part.encoding)
54
67
  end
68
+ extensions[param[:attribute].downcase] = value
55
69
  end
56
- extensions.delete('q')
57
70
  extensions
58
71
  end
59
72
  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,9 +63,19 @@ 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
+ content_type.concat(";q=#{qvalue}")
67
+ end
68
+
69
+ def content_type
70
+ string = media_range
67
71
  if extensions.size > 0
68
- extensions.each { |k, v| string.concat(";#{k}=#{v}") }
72
+ extensions.each do |key, value|
73
+ value = value.to_s
74
+ unless /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]+\z/.match(value.b)
75
+ value = '"' + value.b.gsub(/["\\]/) { |character| "\\#{character}" }.force_encoding(value.encoding) + '"'
76
+ end
77
+ string.concat(";#{key}=#{value}")
78
+ end
69
79
  end
70
80
  string
71
81
  end
@@ -74,18 +84,29 @@ module AcceptHeaders
74
84
  "#{type}/#{subtype}"
75
85
  end
76
86
 
87
+ def specificity
88
+ [type == '*' ? 0 : 1, subtype == '*' ? 0 : 1, extensions.size]
89
+ end
90
+
77
91
  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
92
+ return false if media_range_string.nil? || media_range_string.strip.empty?
93
+ candidates = Negotiator.new(media_range_string).list
94
+ return false unless candidates.size == 1
95
+ candidate = candidates.first
96
+ return false unless (type == '*' || type == candidate.type) &&
97
+ (subtype == '*' || subtype == candidate.subtype)
98
+
99
+ extensions.all? do |name, value|
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
105
+ if name == 'charset'
106
+ supported_value && value.casecmp?(supported_value)
107
+ else
108
+ value == supported_value
109
+ end
89
110
  end
90
111
  end
91
112
  end
@@ -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
@@ -5,29 +7,29 @@ module AcceptHeaders
5
7
 
6
8
  attr_reader :list
7
9
 
8
- Q_PATTERN = /(?:\A|;)\s*(?<exists>qs*\=)\s*(?:(?<q>0\.\d{1,3}|[01])|(?:[^;]*))\s*(?:\z|;)/
10
+ Q_PATTERN = /\A[ \t]*q[ \t]*=[ \t]*(?<q>.*?)[ \t]*\z/im
11
+ QVALUE_PATTERN = /\A(?:0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?)\z/
12
+ SUPPORTED_CONTROL_PATTERN = /[\x00-\x08\x0A-\x1F\x7F]/n
9
13
 
10
14
  def initialize(header)
15
+ header = header.b if header && !header.valid_encoding?
11
16
  @list = parse(header)
12
17
  end
13
18
 
14
19
  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
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) }
23
+ part, header_index = matches.max_by do |match, _|
24
+ [match.specificity, match.q.zero? ? 1 : 0, match.q]
28
25
  end
26
+ next if part.nil? || part.q.zero?
27
+ [support, part, header_index, support_index]
28
+ end.compact
29
+ best = candidates.max_by do |_, part, header_index, support_index|
30
+ [part.q, part.specificity, -header_index, -support_index]
29
31
  end
30
- nil
32
+ best && best.first(2)
31
33
  end
32
34
 
33
35
  def accept?(other)
@@ -39,6 +41,23 @@ module AcceptHeaders
39
41
  end
40
42
 
41
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
+
42
61
  def no_header
43
62
  raise NotImplementedError.new("#no_header is not implemented")
44
63
  end
@@ -50,11 +69,11 @@ module AcceptHeaders
50
69
  def parse(header, &block)
51
70
  return no_header unless header
52
71
  header = header.dup
53
- header.sub!(/\A#{self.class::HEADER_PREFIX}\s*/, '')
72
+ header.sub!(/\A#{self.class::HEADER_PREFIX}\s*/i, '')
54
73
  header.strip!
55
- return no_header if header.empty?
74
+ return [] if header.empty?
56
75
  list = []
57
- header.split(',').each do |entry|
76
+ split_quoted(header, ',').each do |entry|
58
77
  begin
59
78
  item = parse_item(entry)
60
79
  next if item.nil?
@@ -64,20 +83,43 @@ module AcceptHeaders
64
83
  end
65
84
  end
66
85
  list.sort! { |x,y| y <=> x }
86
+ rescue ParseError
87
+ []
67
88
  end
68
89
 
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
90
+ def split_quoted(value, delimiter)
91
+ parts = []
92
+ part = +"".b
93
+ quoted = false
94
+ escaped = false
95
+ value.b.each_char do |character|
96
+ if escaped
97
+ escaped = false
98
+ elsif quoted && character == "\\"
99
+ escaped = true
100
+ elsif character == '"'
101
+ quoted = !quoted
102
+ elsif !quoted && character == delimiter
103
+ parts << part
104
+ part = +"".b
105
+ next
78
106
  end
107
+ part << character
108
+ end
109
+ raise ParseError if quoted || escaped
110
+ parts << part
111
+ parts.map { |segment| segment.force_encoding(value.encoding) }
112
+ end
113
+
114
+ def parse_q(header)
115
+ return 1 unless header
116
+ split_quoted(header, ';').each do |parameter|
117
+ q_match = Q_PATTERN.match(parameter)
118
+ next unless q_match
119
+ value = q_match[:q]
120
+ return QVALUE_PATTERN.match(value) ? value.to_f : 0.001
79
121
  end
80
- q
122
+ 1
81
123
  end
82
124
  end
83
125
  end
@@ -1,3 +1,3 @@
1
1
  module AcceptHeaders
2
- VERSION = "0.1.0"
2
+ VERSION = "0.3.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
+ assert_raises(AcceptHeaders::InvalidSupportedValue) { negotiator.negotiate(coding) }
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