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
165 changes: 165 additions & 0 deletions lib/protocol/http/header/range.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# frozen_string_literal: true

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

require_relative "../error"

module Protocol
module HTTP
module Header
# Represents a `range` request header.
class Range
ParseError = Class.new(Error)

TOKEN = /[!#$%&'*+\-.0-9A-Z^_`a-z|~]+/
HEADER = /\A(?<unit>#{TOKEN})=(?<ranges>.*)\z/
BYTE_RANGE = /\A(?:(?<first>\d+)-(?<last>\d*)|-(?<suffix>\d+))\z/
OTHER_RANGE = /\A[\x21-\x2B\x2D-\x7E]+\z/
SEPARATOR = /\s*,\s*/

# Represents one byte-range-spec or suffix-byte-range-spec.
ByteRange = Struct.new(:first, :last) do
# Parse one byte range.
# @parameter value [String] The byte range to parse.
# @returns [ByteRange] The parsed byte range.
def self.parse(value)
unless match = BYTE_RANGE.match(value)
raise ParseError, "Invalid byte range: #{value.inspect}"
end

if suffix = match[:suffix]
return self.new(nil, Integer(suffix))
else
first = Integer(match[:first])
last = match[:last]
last = last.empty? ? nil : Integer(last)

if last && last < first
raise ParseError, "Invalid byte range: #{value.inspect}"
end

return self.new(first, last)
end
end

# Resolve this byte range against the selected representation size.
# @parameter size [Integer] The size of the selected representation.
# @returns [::Range | Nil] The resolved range, or `nil` when it is unsatisfiable.
def resolve(size)
if first
if first < size
return first..[last || size - 1, size - 1].min
end
elsif last > 0 && size > 0
return [0, size - last].max..size - 1
end

return nil
end

# Convert this byte range to its wire representation.
# @returns [String] The serialized byte range.
def to_s
if first
"#{first}-#{last}"
else
"-#{last}"
end
end
end

# Parse a raw range header value.
# @parameter value [String] The raw header value.
# @returns [Range] The parsed range header.
def self.parse(value)
unless match = HEADER.match(value)
raise ParseError, "Invalid range header: #{value.inspect}"
end

unit = match[:unit].downcase
ranges = match[:ranges].split(SEPARATOR, -1)

if ranges.empty? || ranges.any?(&:empty?)
raise ParseError, "Invalid range set: #{match[:ranges].inspect}"
end

if unit == "bytes"
ranges.map!{|range| ByteRange.parse(range)}
elsif ranges.any?{|range| !OTHER_RANGE.match?(range)}
raise ParseError, "Invalid range set: #{match[:ranges].inspect}"
end

return self.new(unit, ranges)
end

# Coerce a value into a range header.
# @parameter value [Object] The value to coerce.
# @returns [Range] The parsed range header.
def self.coerce(value)
self.parse(value.to_s)
end

# Initialize a range header.
# @parameter unit [String] The range unit.
# @parameter ranges [Array] The range specifiers.
def initialize(unit, ranges)
@unit = unit
@ranges = ranges
end

# @attribute [String] The range unit.
attr :unit

# @attribute [Array] The range specifiers.
attr :ranges

# Whether this header contains byte ranges.
# @returns [Boolean] Whether the range unit is `bytes`.
def bytes?
@unit == "bytes"
end

# Resolve all byte ranges against the selected representation size.
# @parameter size [Integer] The size of the selected representation.
# @returns [Array(::Range)] The satisfiable byte ranges.
def resolve(size)
unless bytes?
raise ArgumentError, "Cannot resolve #{@unit.inspect} ranges as byte ranges!"
end

size = Integer(size)
raise ArgumentError, "Size must not be negative!" if size < 0

@ranges.filter_map{|range| range.resolve(size)}
end

# Combine another raw range header value with this one.
# @parameter value [String] The raw range header value.
def <<(value)
other = self.class.parse(value)

unless other.unit == @unit
raise ParseError, "Cannot combine range units: #{@unit.inspect} and #{other.unit.inspect}"
end

@ranges.concat(other.ranges)

return self
end

# Convert this header to its wire representation.
# @returns [String] The serialized range header.
def to_s
"#{@unit}=#{@ranges.join(",")}"
end

# Whether this header is acceptable in HTTP trailers.
# @returns [Boolean] `false`, as range headers apply to a selected representation.
def self.trailer?
false
end
end
end
end
end
3 changes: 2 additions & 1 deletion lib/protocol/http/headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
require_relative "header/authorization"
require_relative "header/date"
require_relative "header/priority"
require_relative "header/range"
require_relative "header/trailer"
require_relative "header/server_timing"
require_relative "header/digest"
Expand Down Expand Up @@ -347,7 +348,7 @@ def merge(headers)
"host" => false,
"location" => false,
"max-forwards" => false,
"range" => false,
"range" => Header::Range,
"referer" => false,
"retry-after" => false,
"server" => false,
Expand Down
4 changes: 4 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ Please see the [project documentation](https://socketry.github.io/protocol-http/

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

### Unreleased

- Parse and resolve HTTP `Range` header values according to the default headers policy.

### v0.66.0

- Introduce `Protocol::HTTP::RemoteError` for remote endpoint failures where application processing may have occurred.
Expand Down
4 changes: 4 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Releases

## Unreleased

- Parse and resolve HTTP `Range` header values according to the default headers policy.

## v0.66.0

- Introduce `Protocol::HTTP::RemoteError` for remote endpoint failures where application processing may have occurred.
Expand Down
114 changes: 114 additions & 0 deletions test/protocol/http/header/range.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# frozen_string_literal: true

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

require "protocol/http/header/range"

describe Protocol::HTTP::Header::Range do
with ".parse" do
it "parses byte ranges" do
header = subject.parse("bytes=0-4, 10-, -5")

expect(header.unit).to be == "bytes"
expect(header.ranges).to be == [
subject::ByteRange.new(0, 4),
subject::ByteRange.new(10, nil),
subject::ByteRange.new(nil, 5),
]
expect(header.to_s).to be == "bytes=0-4,10-,-5"
end

it "normalizes the unit" do
header = subject.parse("BYTES=0-4")

expect(header.unit).to be == "bytes"
end

it "preserves extension ranges" do
header = subject.parse("example=alpha, beta")

expect(header.unit).to be == "example"
expect(header.ranges).to be == ["alpha", "beta"]
end

it "rejects malformed headers" do
[
"bytes",
"bytes=",
"bytes=0-1,",
"bytes=-",
"bytes=4-1",
"bytes=one-two",
"example=alpha beta",
].each do |value|
expect{subject.parse(value)}.to raise_exception(subject::ParseError)
end
end
end

with "#resolve" do
it "resolves bounded byte ranges" do
header = subject.parse("bytes=1-4")

expect(header.resolve(12)).to be == [1..4]
end

it "resolves open-ended byte ranges" do
header = subject.parse("bytes=8-")

expect(header.resolve(12)).to be == [8..11]
end

it "resolves suffix byte ranges" do
header = subject.parse("bytes=-5")

expect(header.resolve(12)).to be == [7..11]
end

it "clamps byte ranges to the representation size" do
header = subject.parse("bytes=1-999,-999")

expect(header.resolve(12)).to be == [1..11, 0..11]
end

it "omits unsatisfiable byte ranges" do
header = subject.parse("bytes=999-1000,-0")

expect(header.resolve(12)).to be == []
end

it "does not resolve extension ranges as byte ranges" do
header = subject.parse("example=alpha")

expect{header.resolve(12)}.to raise_exception(ArgumentError)
end

it "rejects a negative representation size" do
header = subject.parse("bytes=0-1")

expect{header.resolve(-1)}.to raise_exception(ArgumentError)
end
end

with "#<<" do
it "combines ranges with the same unit" do
header = subject.parse("bytes=0-1")
header << "bytes=4-5"

expect(header.resolve(10)).to be == [0..1, 4..5]
end

it "rejects ranges with a different unit" do
header = subject.parse("bytes=0-1")

expect{header << "example=alpha"}.to raise_exception(subject::ParseError)
end
end

with ".trailer?" do
it "is not allowed in trailers" do
expect(subject).not.to be(:trailer?)
end
end
end
26 changes: 26 additions & 0 deletions test/protocol/http/headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,37 @@
end

with "#to_h" do
it "parses range headers according to the policy" do
headers = subject[[["range", "bytes=1-4"]]]
range = headers["range"]

expect(range).to be_a(Protocol::HTTP::Header::Range)
expect(range.resolve(12)).to be == [1..4]
end

it "combines repeated range headers according to the policy" do
headers = subject[[
["range", "bytes=1-4"],
["range", "bytes=8-"],
]]

expect(headers["range"].resolve(12)).to be == [1..4, 8..11]
end

it "should generate array values for duplicate keys" do
expect(headers.to_h["set-cookie"]).to be == ["hello=world", "foo=bar"]
end
end

with "#[]=" do
it "coerces range header values according to the policy" do
headers["range"] = "bytes=-4"

expect(headers["range"]).to be_a(Protocol::HTTP::Header::Range)
expect(headers["range"].resolve(12)).to be == [8..11]
end
end

with "#inspect" do
it "should generate a string representation" do
expect(headers.inspect).to be == "#<Protocol::HTTP::Headers #{fields.inspect}>"
Expand Down
Loading