Skip to content

Commit 51dfa71

Browse files
Parse Range header values.
1 parent 5a09a3b commit 51dfa71

6 files changed

Lines changed: 314 additions & 1 deletion

File tree

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

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require_relative "../error"
7+
8+
module Protocol
9+
module HTTP
10+
module Header
11+
# Represents a `range` request header.
12+
class Range
13+
ParseError = Class.new(Error)
14+
15+
TOKEN = /[!#$%&'*+\-.0-9A-Z^_`a-z|~]+/
16+
HEADER = /\A(?<unit>#{TOKEN})=(?<ranges>.*)\z/
17+
BYTE_RANGE = /\A(?:(?<first>\d+)-(?<last>\d*)|-(?<suffix>\d+))\z/
18+
OTHER_RANGE = /\A[\x21-\x2B\x2D-\x7E]+\z/
19+
SEPARATOR = /\s*,\s*/
20+
21+
# Represents one byte-range-spec or suffix-byte-range-spec.
22+
ByteRange = Struct.new(:first, :last) do
23+
# Parse one byte range.
24+
# @parameter value [String] The byte range to parse.
25+
# @returns [ByteRange] The parsed byte range.
26+
def self.parse(value)
27+
unless match = BYTE_RANGE.match(value)
28+
raise ParseError, "Invalid byte range: #{value.inspect}"
29+
end
30+
31+
if suffix = match[:suffix]
32+
self.new(nil, Integer(suffix))
33+
else
34+
first = Integer(match[:first])
35+
last = match[:last]
36+
last = last.empty? ? nil : Integer(last)
37+
38+
if last && last < first
39+
raise ParseError, "Invalid byte range: #{value.inspect}"
40+
end
41+
42+
self.new(first, last)
43+
end
44+
end
45+
46+
# Resolve this byte range against the selected representation size.
47+
# @parameter size [Integer] The size of the selected representation.
48+
# @returns [::Range | Nil] The resolved range, or `nil` when it is unsatisfiable.
49+
def resolve(size)
50+
if first
51+
return if first >= size
52+
first..[last || size - 1, size - 1].min
53+
else
54+
return if last.zero? || size.zero?
55+
56+
[0, size - last].max..size - 1
57+
end
58+
end
59+
60+
# Convert this byte range to its wire representation.
61+
# @returns [String] The serialized byte range.
62+
def to_s
63+
if first
64+
"#{first}-#{last}"
65+
else
66+
"-#{last}"
67+
end
68+
end
69+
end
70+
71+
# Parse a raw range header value.
72+
# @parameter value [String] The raw header value.
73+
# @returns [Range] The parsed range header.
74+
def self.parse(value)
75+
unless match = HEADER.match(value)
76+
raise ParseError, "Invalid range header: #{value.inspect}"
77+
end
78+
79+
unit = match[:unit].downcase
80+
ranges = match[:ranges].split(SEPARATOR, -1)
81+
82+
if ranges.empty? || ranges.any?(&:empty?)
83+
raise ParseError, "Invalid range set: #{match[:ranges].inspect}"
84+
end
85+
86+
if unit == "bytes"
87+
ranges.map!{|range| ByteRange.parse(range)}
88+
elsif ranges.any?{|range| !OTHER_RANGE.match?(range)}
89+
raise ParseError, "Invalid range set: #{match[:ranges].inspect}"
90+
end
91+
92+
self.new(unit, ranges)
93+
end
94+
95+
# Coerce a value into a range header.
96+
# @parameter value [Object] The value to coerce.
97+
# @returns [Range] The parsed range header.
98+
def self.coerce(value)
99+
self.parse(value.to_s)
100+
end
101+
102+
# Initialize a range header.
103+
# @parameter unit [String] The range unit.
104+
# @parameter ranges [Array] The range specifiers.
105+
def initialize(unit, ranges)
106+
@unit = unit
107+
@ranges = ranges
108+
end
109+
110+
# @attribute [String] The range unit.
111+
attr :unit
112+
113+
# @attribute [Array] The range specifiers.
114+
attr :ranges
115+
116+
# Whether this header contains byte ranges.
117+
# @returns [Boolean] Whether the range unit is `bytes`.
118+
def bytes?
119+
@unit == "bytes"
120+
end
121+
122+
# Resolve all byte ranges against the selected representation size.
123+
# @parameter size [Integer] The size of the selected representation.
124+
# @returns [Array(::Range)] The satisfiable byte ranges.
125+
def resolve(size)
126+
unless bytes?
127+
raise ArgumentError, "Cannot resolve #{@unit.inspect} ranges as byte ranges!"
128+
end
129+
130+
size = Integer(size)
131+
raise ArgumentError, "Size must not be negative!" if size < 0
132+
133+
@ranges.filter_map{|range| range.resolve(size)}
134+
end
135+
136+
# Combine another raw range header value with this one.
137+
# @parameter value [String] The raw range header value.
138+
def <<(value)
139+
other = self.class.parse(value)
140+
141+
unless other.unit == @unit
142+
raise ParseError, "Cannot combine range units: #{@unit.inspect} and #{other.unit.inspect}"
143+
end
144+
145+
@ranges.concat(other.ranges)
146+
147+
return self
148+
end
149+
150+
# Convert this header to its wire representation.
151+
# @returns [String] The serialized range header.
152+
def to_s
153+
"#{@unit}=#{@ranges.join(",")}"
154+
end
155+
156+
# Whether this header is acceptable in HTTP trailers.
157+
# @returns [Boolean] `false`, as range headers apply to a selected representation.
158+
def self.trailer?
159+
false
160+
end
161+
end
162+
end
163+
end
164+
end

‎lib/protocol/http/headers.rb‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
require_relative "header/authorization"
1919
require_relative "header/date"
2020
require_relative "header/priority"
21+
require_relative "header/range"
2122
require_relative "header/trailer"
2223
require_relative "header/server_timing"
2324
require_relative "header/digest"
@@ -347,7 +348,7 @@ def merge(headers)
347348
"host" => false,
348349
"location" => false,
349350
"max-forwards" => false,
350-
"range" => false,
351+
"range" => Header::Range,
351352
"referer" => false,
352353
"retry-after" => false,
353354
"server" => false,

‎readme.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ Please see the [project documentation](https://socketry.github.io/protocol-http/
3030

3131
Please see the [project releases](https://socketry.github.io/protocol-http/releases/index) for all releases.
3232

33+
### Unreleased
34+
35+
- Parse and resolve HTTP `Range` header values according to the default headers policy.
36+
3337
### v0.66.0
3438

3539
- Introduce `Protocol::HTTP::RemoteError` for remote endpoint failures where application processing may have occurred.

‎releases.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Releases
22

3+
## Unreleased
4+
5+
- Parse and resolve HTTP `Range` header values according to the default headers policy.
6+
37
## v0.66.0
48

59
- Introduce `Protocol::HTTP::RemoteError` for remote endpoint failures where application processing may have occurred.

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

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2026, by Samuel Williams.
5+
6+
require "protocol/http/header/range"
7+
8+
describe Protocol::HTTP::Header::Range do
9+
with ".parse" do
10+
it "parses byte ranges" do
11+
header = subject.parse("bytes=0-4, 10-, -5")
12+
13+
expect(header.unit).to be == "bytes"
14+
expect(header.ranges).to be == [
15+
subject::ByteRange.new(0, 4),
16+
subject::ByteRange.new(10, nil),
17+
subject::ByteRange.new(nil, 5),
18+
]
19+
expect(header.to_s).to be == "bytes=0-4,10-,-5"
20+
end
21+
22+
it "normalizes the unit" do
23+
header = subject.parse("BYTES=0-4")
24+
25+
expect(header.unit).to be == "bytes"
26+
end
27+
28+
it "preserves extension ranges" do
29+
header = subject.parse("example=alpha, beta")
30+
31+
expect(header.unit).to be == "example"
32+
expect(header.ranges).to be == ["alpha", "beta"]
33+
end
34+
35+
it "rejects malformed headers" do
36+
[
37+
"bytes",
38+
"bytes=",
39+
"bytes=0-1,",
40+
"bytes=-",
41+
"bytes=4-1",
42+
"bytes=one-two",
43+
"example=alpha beta",
44+
].each do |value|
45+
expect{subject.parse(value)}.to raise_exception(subject::ParseError)
46+
end
47+
end
48+
end
49+
50+
with "#resolve" do
51+
it "resolves bounded byte ranges" do
52+
header = subject.parse("bytes=1-4")
53+
54+
expect(header.resolve(12)).to be == [1..4]
55+
end
56+
57+
it "resolves open-ended byte ranges" do
58+
header = subject.parse("bytes=8-")
59+
60+
expect(header.resolve(12)).to be == [8..11]
61+
end
62+
63+
it "resolves suffix byte ranges" do
64+
header = subject.parse("bytes=-5")
65+
66+
expect(header.resolve(12)).to be == [7..11]
67+
end
68+
69+
it "clamps byte ranges to the representation size" do
70+
header = subject.parse("bytes=1-999,-999")
71+
72+
expect(header.resolve(12)).to be == [1..11, 0..11]
73+
end
74+
75+
it "omits unsatisfiable byte ranges" do
76+
header = subject.parse("bytes=999-1000,-0")
77+
78+
expect(header.resolve(12)).to be == []
79+
end
80+
81+
it "does not resolve extension ranges as byte ranges" do
82+
header = subject.parse("example=alpha")
83+
84+
expect{header.resolve(12)}.to raise_exception(ArgumentError)
85+
end
86+
87+
it "rejects a negative representation size" do
88+
header = subject.parse("bytes=0-1")
89+
90+
expect{header.resolve(-1)}.to raise_exception(ArgumentError)
91+
end
92+
end
93+
94+
with "#<<" do
95+
it "combines ranges with the same unit" do
96+
header = subject.parse("bytes=0-1")
97+
header << "bytes=4-5"
98+
99+
expect(header.resolve(10)).to be == [0..1, 4..5]
100+
end
101+
102+
it "rejects ranges with a different unit" do
103+
header = subject.parse("bytes=0-1")
104+
105+
expect{header << "example=alpha"}.to raise_exception(subject::ParseError)
106+
end
107+
end
108+
109+
with ".trailer?" do
110+
it "is not allowed in trailers" do
111+
expect(subject).not.to be(:trailer?)
112+
end
113+
end
114+
end

‎test/protocol/http/headers.rb‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,37 @@
174174
end
175175

176176
with "#to_h" do
177+
it "parses range headers according to the policy" do
178+
headers = subject[[["range", "bytes=1-4"]]]
179+
range = headers["range"]
180+
181+
expect(range).to be_a(Protocol::HTTP::Header::Range)
182+
expect(range.resolve(12)).to be == [1..4]
183+
end
184+
185+
it "combines repeated range headers according to the policy" do
186+
headers = subject[[
187+
["range", "bytes=1-4"],
188+
["range", "bytes=8-"],
189+
]]
190+
191+
expect(headers["range"].resolve(12)).to be == [1..4, 8..11]
192+
end
193+
177194
it "should generate array values for duplicate keys" do
178195
expect(headers.to_h["set-cookie"]).to be == ["hello=world", "foo=bar"]
179196
end
180197
end
181198

199+
with "#[]=" do
200+
it "coerces range header values according to the policy" do
201+
headers["range"] = "bytes=-4"
202+
203+
expect(headers["range"]).to be_a(Protocol::HTTP::Header::Range)
204+
expect(headers["range"].resolve(12)).to be == [8..11]
205+
end
206+
end
207+
182208
with "#inspect" do
183209
it "should generate a string representation" do
184210
expect(headers.inspect).to be == "#<Protocol::HTTP::Headers #{fields.inspect}>"

0 commit comments

Comments
 (0)