Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 39 additions & 10 deletions lib/protocol/http/header/cookie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
# Released under the MIT License.
# Copyright, 2019-2026, by Samuel Williams.

require_relative "multiple"
require_relative "../cookie"

module Protocol
Expand All @@ -12,7 +11,40 @@ module Header
# The `cookie` header contains stored HTTP cookies previously sent by the server with the `set-cookie` header.
#
# It is used by clients to send key-value pairs representing stored cookies back to the server.
class Cookie < Multiple
# Multiple cookies within a single `Cookie` header are joined with `"; "` per RFC 6265.
class Cookie < Array
# Parses a raw header value.
#
# @parameter value [String] a single raw header value.
# @returns [Cookie] a new instance containing the parsed value.
def self.parse(value)
self.new([value])
end

# Coerces a value into a parsed header object.
#
# @parameter value [String | Array] the value to coerce.
# @returns [Cookie] a parsed header object.
def self.coerce(value)
case value
when Array
self.new(value.map(&:to_s))
else
self.parse(value.to_s)
end
end

# Initializes the cookie header with the given values.
#
# @parameter value [Array | Nil] an array of cookie strings, or `nil` for an empty header.
def initialize(value = nil)
super()

if value
self.concat(value)
end
end

# Parses the `cookie` header into a hash of cookie names and their corresponding cookie objects.
#
# @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects.
Expand All @@ -24,9 +56,9 @@ def to_h
cookies.map{|cookie| [cookie.name, cookie]}.to_h
end

# Serializes the `cookie` header by joining individual cookie strings with semicolons.
# Serializes the `cookie` header by joining individual cookie strings with `"; "` per RFC 6265.
def to_s
join(";")
join("; ")
end

# Whether this header is acceptable in HTTP trailers.
Expand All @@ -36,12 +68,9 @@ def self.trailer?
false
end
end

# The `set-cookie` header sends cookies from the server to the user agent.
#
# 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.
class SetCookie < Cookie
end
end
end
end

# Preserve the existing behavior where requiring this file also defines SetCookie.
require_relative "set_cookie"
37 changes: 37 additions & 0 deletions lib/protocol/http/header/set_cookie.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2019-2026, by Samuel Williams.

require_relative "multiple"
require_relative "../cookie"

module Protocol
module HTTP
module Header
# The `set-cookie` header sends cookies from the server to the user agent.
#
# Each `Set-Cookie` header must be a separate header field — they cannot be combined.
# 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.
class SetCookie < Multiple
# Parses the `set-cookie` headers into a hash of cookie names and their corresponding cookie objects.
#
# @returns [Hash(String, HTTP::Cookie)] a hash where keys are cookie names and values are {HTTP::Cookie} objects.
def to_h
cookies = self.collect do |string|
HTTP::Cookie.parse(string)
end

cookies.map{|cookie| [cookie.name, cookie]}.to_h
end

# Whether this header is acceptable in HTTP trailers.
# @returns [Boolean] `false`, as set-cookie headers are needed during initial response processing.
def self.trailer?
false
end
end
end
end
end
9 changes: 8 additions & 1 deletion lib/protocol/http/headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
require_relative "header/multiple"

require_relative "header/cookie"
require_relative "header/set_cookie"
require_relative "header/connection"
require_relative "header/cache_control"
require_relative "header/etag"
Expand Down Expand Up @@ -300,7 +301,13 @@ def []=(key, value)
@indexed[key] = value
end

@fields << [key, value.to_s]
if value.is_a?(Multiple)
value.each do |v|
@fields << [key, v.to_s]
end
else
@fields << [key, value.to_s]
end
end

# Get the value of the specified header key.
Expand Down
1 change: 1 addition & 0 deletions releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

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

## v0.64.0

Expand Down
10 changes: 8 additions & 2 deletions test/protocol/http/header/cookie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
let(:header) {subject.parse(description)}
let(:cookies) {header.to_h}

it "can coerce a single value" do
header = subject.coerce("session=abc123")

expect(header).to be == ["session=abc123"]
end

with "session=123; secure" do
it "can parse cookies" do
expect(cookies).to have_keys("session")
Expand Down Expand Up @@ -56,8 +62,8 @@
cookie
end

it "joins cookies with semicolons without spaces" do
expect(header.to_s).to be == "session=abc123;user_id=42;token=xyz789"
it "joins cookies with semicolons and spaces per RFC 6265" do
expect(header.to_s).to be == "session=abc123; user_id=42; token=xyz789"
end
end
end
24 changes: 24 additions & 0 deletions test/protocol/http/header/set_cookie.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "protocol/http/header/set_cookie"

describe Protocol::HTTP::Header::SetCookie do
let(:header) do
subject.coerce([
"session=abc123; Path=/",
"theme=dark; HttpOnly",
])
end

it "represents values which must be emitted as separate fields" do
expect(header).to be_a(Protocol::HTTP::Header::Multiple)
expect(header).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"]
end

it "can extract parsed cookies" do
expect(header.to_h).to have_keys("session", "theme")
end
end
31 changes: 30 additions & 1 deletion test/protocol/http/headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,35 @@
expect(headers["accept-encoding"]).to be(:include?, "deflate")
end

it "joins multiple cookie values into one field" do
headers["cookie"] = ["session=abc123", "theme=dark"]

expect(headers.fields.select{|key, value| key == "cookie"}).to be == [
["cookie", "session=abc123; theme=dark"],
]
end

it "adds multiple set-cookie values as separate fields" do
headers["set-cookie"] = ["session=abc123; Path=/", "theme=dark; HttpOnly"]

expect(headers.fields.select{|key, value| key == "set-cookie"}).to be == [
["set-cookie", "session=abc123; Path=/"],
["set-cookie", "theme=dark; HttpOnly"],
]
expect(headers["set-cookie"]).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"]
end

it "adds multiple set-cookie values after indexing headers" do
headers.to_h
headers["set-cookie"] = ["session=abc123; Path=/", "theme=dark; HttpOnly"]

expect(headers.fields.select{|key, value| key == "set-cookie"}).to be == [
["set-cookie", "session=abc123; Path=/"],
["set-cookie", "theme=dark; HttpOnly"],
]
expect(headers["set-cookie"]).to be == ["session=abc123; Path=/", "theme=dark; HttpOnly"]
end

it "can add field with indexed hash" do
expect(headers.to_h).not.to be(:empty?)

Expand Down Expand Up @@ -600,7 +629,7 @@ def self.trailer?

with "set-cookie" do
it "can extract parsed cookies" do
expect(headers["set-cookie"]).to be_a(Protocol::HTTP::Header::Cookie)
expect(headers["set-cookie"]).to be_a(Protocol::HTTP::Header::SetCookie)
end
end

Expand Down
Loading