Skip to content

Commit 2effe9d

Browse files
authored
Use multiple fields for set-cookie header. (#101)
1 parent 787d6db commit 2effe9d

7 files changed

Lines changed: 147 additions & 14 deletions

File tree

‎lib/protocol/http/header/cookie.rb‎

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
# Released under the MIT License.
44
# Copyright, 2019-2026, by Samuel Williams.
55

6-
require_relative "multiple"
76
require_relative "../cookie"
87

98
module Protocol
@@ -12,7 +11,40 @@ module Header
1211
# The `cookie` header contains stored HTTP cookies previously sent by the server with the `set-cookie` header.
1312
#
1413
# It is used by clients to send key-value pairs representing stored cookies back to the server.
15-
class Cookie < Multiple
14+
# Multiple cookies within a single `Cookie` header are joined with `"; "` per RFC 6265.
15+
class Cookie < Array
16+
# Parses a raw header value.
17+
#
18+
# @parameter value [String] a single raw header value.
19+
# @returns [Cookie] a new instance containing the parsed value.
20+
def self.parse(value)
21+
self.new([value])
22+
end
23+
24+
# Coerces a value into a parsed header object.
25+
#
26+
# @parameter value [String | Array] the value to coerce.
27+
# @returns [Cookie] a parsed header object.
28+
def self.coerce(value)
29+
case value
30+
when Array
31+
self.new(value.map(&:to_s))
32+
else
33+
self.parse(value.to_s)
34+
end
35+
end
36+
37+
# Initializes the cookie header with the given values.
38+
#
39+
# @parameter value [Array | Nil] an array of cookie strings, or `nil` for an empty header.
40+
def initialize(value = nil)
41+
super()
42+
43+
if value
44+
self.concat(value)
45+
end
46+
end
47+
1648
# Parses the `cookie` header into a hash of cookie names and their corresponding cookie objects.
1749
#
1850
# @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects.
@@ -24,9 +56,9 @@ def to_h
2456
cookies.map{|cookie| [cookie.name, cookie]}.to_h
2557
end
2658

27-
# Serializes the `cookie` header by joining individual cookie strings with semicolons.
59+
# Serializes the `cookie` header by joining individual cookie strings with `"; "` per RFC 6265.
2860
def to_s
29-
join(";")
61+
join("; ")
3062
end
3163

3264
# Whether this header is acceptable in HTTP trailers.
@@ -36,12 +68,9 @@ def self.trailer?
3668
false
3769
end
3870
end
39-
40-
# The `set-cookie` header sends cookies from the server to the user agent.
41-
#
42-
# It is used to store cookies on the client side, which are then sent back to the server in subsequent requests using the `cookie` header.
43-
class SetCookie < Cookie
44-
end
4571
end
4672
end
4773
end
74+
75+
# Preserve the existing behavior where requiring this file also defines SetCookie.
76+
require_relative "set_cookie"
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2019-2026, by Samuel Williams.
5+
6+
require_relative "multiple"
7+
require_relative "../cookie"
8+
9+
module Protocol
10+
module HTTP
11+
module Header
12+
# The `set-cookie` header sends cookies from the server to the user agent.
13+
#
14+
# Each `Set-Cookie` header must be a separate header field — they cannot be combined.
15+
# It is used to store cookies on the client side, which are then sent back to the server
16+
# in subsequent requests using the `cookie` header.
17+
class SetCookie < Multiple
18+
# Parses the `set-cookie` headers into a hash of cookie names and their corresponding cookie objects.
19+
#
20+
# @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects.
21+
def to_h
22+
cookies = self.collect do |string|
23+
HTTP::Cookie.parse(string)
24+
end
25+
26+
cookies.map{|cookie| [cookie.name, cookie]}.to_h
27+
end
28+
29+
# Whether this header is acceptable in HTTP trailers.
30+
# @returns [Boolean] `false`, as set-cookie headers are needed during initial response processing.
31+
def self.trailer?
32+
false
33+
end
34+
end
35+
end
36+
end
37+
end

‎lib/protocol/http/headers.rb‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
require_relative "header/multiple"
1010

1111
require_relative "header/cookie"
12+
require_relative "header/set_cookie"
1213
require_relative "header/connection"
1314
require_relative "header/cache_control"
1415
require_relative "header/etag"
@@ -300,7 +301,13 @@ def []=(key, value)
300301
@indexed[key] = value
301302
end
302303

303-
@fields << [key, value.to_s]
304+
if value.is_a?(Multiple)
305+
value.each do |v|
306+
@fields << [key, v.to_s]
307+
end
308+
else
309+
@fields << [key, value.to_s]
310+
end
304311
end
305312

306313
# Get the value of the specified header key.

‎releases.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Improve `Accept` header parsing for quoted pairs, malformed parameters, invalid wildcards, and invalid quality factors.
6+
- Emit multiple `Set-Cookie` values as separate header fields and delimit combined `Cookie` values with a space after each semicolon.
67

78
## v0.64.0
89

‎test/protocol/http/header/cookie.rb‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@
1010
let(:header) {subject.parse(description)}
1111
let(:cookies) {header.to_h}
1212

13+
it "can coerce a single value" do
14+
header = subject.coerce("session=abc123")
15+
16+
expect(header).to be == ["session=abc123"]
17+
end
18+
1319
with "session=123; secure" do
1420
it "can parse cookies" do
1521
expect(cookies).to have_keys("session")
@@ -56,8 +62,8 @@
5662
cookie
5763
end
5864

59-
it "joins cookies with semicolons without spaces" do
60-
expect(header.to_s).to be == "session=abc123;user_id=42;token=xyz789"
65+
it "joins cookies with semicolons and spaces per RFC 6265" do
66+
expect(header.to_s).to be == "session=abc123; user_id=42; token=xyz789"
6167
end
6268
end
6369
end
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "protocol/http/header/set_cookie"
7+
8+
describe Protocol::HTTP::Header::SetCookie do
9+
let(:header) do
10+
subject.coerce([
11+
"session=abc123; Path=/",
12+
"theme=dark; HttpOnly",
13+
])
14+
end
15+
16+
it "represents values which must be emitted as separate fields" do
17+
expect(header).to be_a(Protocol::HTTP::Header::Multiple)
18+
expect(header).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"]
19+
end
20+
21+
it "can extract parsed cookies" do
22+
expect(header.to_h).to have_keys("session", "theme")
23+
end
24+
end

‎test/protocol/http/headers.rb‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,35 @@
212212
expect(headers["accept-encoding"]).to be(:include?, "deflate")
213213
end
214214

215+
it "joins multiple cookie values into one field" do
216+
headers["cookie"] = ["session=abc123", "theme=dark"]
217+
218+
expect(headers.fields.select{|key, value| key == "cookie"}).to be == [
219+
["cookie", "session=abc123; theme=dark"],
220+
]
221+
end
222+
223+
it "adds multiple set-cookie values as separate fields" do
224+
headers["set-cookie"] = ["session=abc123; Path=/", "theme=dark; HttpOnly"]
225+
226+
expect(headers.fields.select{|key, value| key == "set-cookie"}).to be == [
227+
["set-cookie", "session=abc123; Path=/"],
228+
["set-cookie", "theme=dark; HttpOnly"],
229+
]
230+
expect(headers["set-cookie"]).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"]
231+
end
232+
233+
it "adds multiple set-cookie values after indexing headers" do
234+
headers.to_h
235+
headers["set-cookie"] = ["session=abc123; Path=/", "theme=dark; HttpOnly"]
236+
237+
expect(headers.fields.select{|key, value| key == "set-cookie"}).to be == [
238+
["set-cookie", "session=abc123; Path=/"],
239+
["set-cookie", "theme=dark; HttpOnly"],
240+
]
241+
expect(headers["set-cookie"]).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"]
242+
end
243+
215244
it "can add field with indexed hash" do
216245
expect(headers.to_h).not.to be(:empty?)
217246

@@ -600,7 +629,7 @@ def self.trailer?
600629

601630
with "set-cookie" do
602631
it "can extract parsed cookies" do
603-
expect(headers["set-cookie"]).to be_a(Protocol::HTTP::Header::Cookie)
632+
expect(headers["set-cookie"]).to be_a(Protocol::HTTP::Header::SetCookie)
604633
end
605634
end
606635

0 commit comments

Comments
 (0)