Skip to content

Commit 73bf831

Browse files
Preserve header casing
The original behavior was to normalize all header names so that they were broken up into words, delimited by `-` or `_`, capitalize each word, and then join the words together with a `-`. This made it impossible to make a request with an underscore in the header name, or with a different casing (ex: all caps). However, the normalized name made it possible to access (or delete) headers, without having to know the exact casing. The new behavior is based on the following rules (as specified in #524 (comment)) 1) Fail if a header name is not specified as a String or Symbol 2) If the header name is specified as a Symbol, normalize it when writing it in a request. If the header name is specified as a String, preserve it as-is when writing it in a request. 3) Allow lookup of any header using the normalized form of the name I implemented this behavior by storing three elements for each header value: 1) normalized header name 2) header name as it will be written in a request 3) header value Element 2 is the new addition. I considered just storing the header value as it would be written, and only doing normalization during lookup, but it seemed wasteful to potentially normalize the same value over and over when searching through the list for various lookups. This way we only normalize each name once, and can continue to use that value for lookups. However, whenever asked for the contents (ex: via `each` or `keys`) we return the new, non-normalized name. Fixes: #524
1 parent 427525c commit 73bf831

5 files changed

Lines changed: 68 additions & 18 deletions

File tree

lib/http/headers.rb

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ class Headers
2121

2222
# Class constructor.
2323
def initialize
24+
# The @pile stores each header value using a three element array:
25+
# 0 - the normalized header key, used for lookup
26+
# 1 - the header key as it will be sent with a request
27+
# 2 - the value
2428
@pile = []
2529
end
2630

@@ -49,16 +53,30 @@ def delete(name)
4953
# @param [Array<#to_s>, #to_s] value header value(s) to be appended
5054
# @return [void]
5155
def add(name, value)
52-
name = normalize_header name.to_s
53-
Array(value).each { |v| @pile << [name, validate_value(v)] }
56+
lookup_name = normalize_header(name.to_s)
57+
wire_name = case name
58+
when String
59+
name
60+
when Symbol
61+
lookup_name
62+
else
63+
raise HTTP::HeaderError, "HTTP header must be a String or Symbol: #{name.inspect}"
64+
end
65+
Array(value).each do |v|
66+
@pile << [
67+
lookup_name,
68+
wire_name,
69+
validate_value(v)
70+
]
71+
end
5472
end
5573

5674
# Returns list of header values if any.
5775
#
5876
# @return [Array<String>]
5977
def get(name)
6078
name = normalize_header name.to_s
61-
@pile.select { |k, _| k == name }.map { |_, v| v }
79+
@pile.select { |k, _| k == name }.map { |_, _, v| v }
6280
end
6381

6482
# Smart version of {#get}.
@@ -96,7 +114,7 @@ def to_h
96114
#
97115
# @return [Array<[String, String]>]
98116
def to_a
99-
@pile.map { |pair| pair.map(&:dup) }
117+
@pile.map { |item| item[1..2] }
100118
end
101119

102120
# Returns human-readable representation of `self` instance.
@@ -110,7 +128,7 @@ def inspect
110128
#
111129
# @return [Array<String>]
112130
def keys
113-
@pile.map { |k, _| k }.uniq
131+
@pile.map { |_, k, _| k }.uniq
114132
end
115133

116134
# Compares headers to another Headers or Array of key/value pairs
@@ -119,7 +137,7 @@ def keys
119137
def ==(other)
120138
return false unless other.respond_to? :to_a
121139

122-
@pile == other.to_a
140+
to_a == other.to_a
123141
end
124142

125143
# Calls the given block once for each key/value pair in headers container.
@@ -129,7 +147,7 @@ def ==(other)
129147
def each
130148
return to_enum(__method__) unless block_given?
131149

132-
@pile.each { |arr| yield(arr) }
150+
@pile.each { |item| yield(item[1..2]) }
133151
self
134152
end
135153

@@ -152,7 +170,7 @@ def each
152170
# @api private
153171
def initialize_copy(orig)
154172
super
155-
@pile = to_a
173+
@pile = @pile.map(&:dup)
156174
end
157175

158176
# Merges `other` headers into `self`.

spec/lib/http/connection_spec.rb

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,17 @@
2020
<<-RESPONSE.gsub(/^\s*\| */, "").gsub(/\n/, "\r\n")
2121
| HTTP/1.1 200 OK
2222
| Content-Type: text
23+
| foo_bar: 123
2324
|
2425
RESPONSE
2526
end
2627
end
2728

28-
it "reads data in parts" do
29+
it "populates headers collection, preserving casing" do
2930
connection.read_headers!
30-
expect(connection.headers).to eq("Content-Type" => "text")
31+
expect(connection.headers).to eq("Content-Type" => "text", "foo_bar" => "123")
32+
expect(connection.headers["Foo-Bar"]).to eq("123")
33+
expect(connection.headers["foo_bar"]).to eq("123")
3134
end
3235
end
3336

spec/lib/http/headers_spec.rb

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
expect(headers["Accept"]).to eq "application/json"
1414
end
1515

16-
it "normalizes header name" do
16+
it "allows retrieval via normalized header name" do
1717
headers.set :content_type, "application/json"
1818
expect(headers["Content-Type"]).to eq "application/json"
1919
end
@@ -54,7 +54,7 @@
5454
expect(headers["Accept"]).to eq "application/json"
5555
end
5656

57-
it "normalizes header name" do
57+
it "allows retrieval via normalized header name" do
5858
headers[:content_type] = "application/json"
5959
expect(headers["Content-Type"]).to eq "application/json"
6060
end
@@ -80,7 +80,7 @@
8080
expect(headers["Content-Type"]).to be_nil
8181
end
8282

83-
it "normalizes header name" do
83+
it "removes header that matches normalized version of specified name" do
8484
headers.delete :content_type
8585
expect(headers["Content-Type"]).to be_nil
8686
end
@@ -104,13 +104,13 @@
104104
expect(headers["Accept"]).to eq "application/json"
105105
end
106106

107-
it "normalizes header name" do
107+
it "allows retrieval via normalized header name" do
108108
headers.add :content_type, "application/json"
109109
expect(headers["Content-Type"]).to eq "application/json"
110110
end
111111

112112
it "appends new value if header exists" do
113-
headers.add :set_cookie, "hoo=ray"
113+
headers.add "Set-Cookie", "hoo=ray"
114114
headers.add :set_cookie, "woo=hoo"
115115
expect(headers["Set-Cookie"]).to eq %w[hoo=ray woo=hoo]
116116
end
@@ -137,6 +137,11 @@
137137
expect { headers.add "foo", "bar\nEvil-Header: evil-value" }.
138138
to raise_error HTTP::HeaderError
139139
end
140+
141+
it "fails when header name is not a String or Symbol" do
142+
expect { headers.add 2, "foo" }.
143+
to raise_error HTTP::HeaderError
144+
end
140145
end
141146

142147
describe "#get" do
@@ -314,6 +319,17 @@
314319
)
315320
end
316321

322+
it "yields header keys specified as symbols in normalized form" do
323+
keys = headers.each.map(&:first)
324+
expect(keys).to eq(["Set-Cookie", "Content-Type", "Set-Cookie"])
325+
end
326+
327+
it "yields headers specified as strings without conversion" do
328+
headers.add "X_kEy", "value"
329+
keys = headers.each.map(&:first)
330+
expect(keys).to eq(["Set-Cookie", "Content-Type", "Set-Cookie", "X_kEy"])
331+
end
332+
317333
it "returns self instance if block given" do
318334
expect(headers.each { |*| }).to be headers
319335
end
@@ -490,14 +506,15 @@
490506
end
491507

492508
context "with duplicate header keys (mixed case)" do
493-
let(:headers) { {"Set-Cookie" => "hoo=ray", "set-cookie" => "woo=hoo"} }
509+
let(:headers) { {"Set-Cookie" => "hoo=ray", "set_cookie" => "woo=hoo", :set_cookie => "ta=da"} }
494510

495511
it "adds all headers" do
496512
expect(described_class.coerce(headers).to_a).
497513
to match_array(
498514
[
499515
%w[Set-Cookie hoo=ray],
500-
%w[Set-Cookie woo=hoo]
516+
%w[set_cookie woo=hoo],
517+
%w[Set-Cookie ta=da]
501518
]
502519
)
503520
end

spec/lib/http/options/headers_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
end
99

1010
it "may be specified with with_headers" do
11-
opts2 = opts.with_headers("accept" => "json")
11+
opts2 = opts.with_headers(:accept => "json")
1212
expect(opts.headers).to be_empty
1313
expect(opts2.headers).to eq([%w[Accept json]])
1414
end

spec/lib/http/request/writer_spec.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@
2222
end
2323
end
2424

25+
context "when headers are specified as strings with mixed case" do
26+
let(:headers) { HTTP::Headers.coerce "content-Type" => "text", "X_MAX" => "200" }
27+
28+
it "writes the headers with the same casing" do
29+
writer.stream
30+
expect(io.string).to eq [
31+
"#{headerstart}\r\n",
32+
"content-Type: text\r\nX_MAX: 200\r\nContent-Length: 0\r\n\r\n"
33+
].join
34+
end
35+
end
36+
2537
context "when body is nonempty" do
2638
let(:body) { HTTP::Request::Body.new("content") }
2739

0 commit comments

Comments
 (0)