From 8998f552dda83bd39fdfcda011616730149dafb4 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 12 Dec 2025 19:36:56 +1300 Subject: [PATCH 1/8] `Headers#[]=` handles Array values. - Parsing logic was moved to `Header::*.parse(value)`. - Coercion to header values is now handled by `Header::*.coerce(value)`. --- lib/protocol/http/body/stream.rb | 2 +- lib/protocol/http/body/streamable.rb | 8 +-- lib/protocol/http/header/accept.rb | 43 ++++++++++--- lib/protocol/http/header/authorization.rb | 16 +++++ lib/protocol/http/header/cache_control.rb | 19 ++++-- lib/protocol/http/header/connection.rb | 19 ++++-- lib/protocol/http/header/date.rb | 20 +++++- lib/protocol/http/header/etag.rb | 20 +++++- lib/protocol/http/header/etags.rb | 2 +- lib/protocol/http/header/multiple.rb | 41 ++++++++++-- lib/protocol/http/header/priority.rb | 19 ++++-- lib/protocol/http/header/split.rb | 49 ++++++++++++--- lib/protocol/http/header/te.rb | 19 ++++-- lib/protocol/http/header/transfer_encoding.rb | 19 ++++-- lib/protocol/http/header/vary.rb | 21 +++++-- lib/protocol/http/headers.rb | 63 ++++++++++++++----- releases.md | 7 +++ test/protocol/http/header/accept.rb | 2 +- test/protocol/http/header/accept_charset.rb | 4 +- test/protocol/http/header/accept_encoding.rb | 4 +- test/protocol/http/header/accept_language.rb | 4 +- test/protocol/http/header/cache_control.rb | 19 +++++- test/protocol/http/header/connection.rb | 20 +++++- test/protocol/http/header/cookie.rb | 2 +- test/protocol/http/header/date.rb | 2 +- test/protocol/http/header/digest.rb | 10 +-- test/protocol/http/header/etag.rb | 2 +- test/protocol/http/header/etags.rb | 2 +- test/protocol/http/header/multiple.rb | 2 +- test/protocol/http/header/priority.rb | 20 +++++- test/protocol/http/header/server_timing.rb | 2 +- test/protocol/http/header/te.rb | 22 ++++++- test/protocol/http/header/trailer.rb | 4 +- .../protocol/http/header/transfer_encoding.rb | 20 +++++- test/protocol/http/header/vary.rb | 12 +++- test/protocol/http/headers.rb | 4 +- 36 files changed, 435 insertions(+), 109 deletions(-) diff --git a/lib/protocol/http/body/stream.rb b/lib/protocol/http/body/stream.rb index 912d3695..732750d7 100644 --- a/lib/protocol/http/body/stream.rb +++ b/lib/protocol/http/body/stream.rb @@ -10,7 +10,7 @@ module Protocol module HTTP module Body - # The input stream is an IO-like object which contains the raw HTTP POST data. When applicable, its external encoding must be “ASCII-8BIT” and it must be opened in binary mode, for Ruby 1.9 compatibility. The input stream must respond to gets, each, read and rewind. + # The input stream is an IO-like object which contains the raw HTTP POST data. When applicable, its external encoding must be "ASCII-8BIT" and it must be opened in binary mode, for Ruby 1.9 compatibility. The input stream must respond to gets, each, read and rewind. class Stream # The default line separator, used by {gets}. NEWLINE = "\n" diff --git a/lib/protocol/http/body/streamable.rb b/lib/protocol/http/body/streamable.rb index 20e95af3..7b722569 100644 --- a/lib/protocol/http/body/streamable.rb +++ b/lib/protocol/http/body/streamable.rb @@ -127,10 +127,10 @@ def call(stream) # Ownership of the stream is passed into the block, in other words, the block is responsible for closing the stream. block.call(stream) - rescue => error - # If, for some reason, the block raises an error, we assume it may not have closed the stream, so we close it here: - stream.close - raise + rescue => error + # If, for some reason, the block raises an error, we assume it may not have closed the stream, so we close it here: + stream.close + raise end # Close the input. The streaming body will eventually read all the input. diff --git a/lib/protocol/http/header/accept.rb b/lib/protocol/http/header/accept.rb index 773af895..01781c8b 100644 --- a/lib/protocol/http/header/accept.rb +++ b/lib/protocol/http/header/accept.rb @@ -68,27 +68,54 @@ def quality_factor end end - # Parse the `accept` header value into a list of content types. + # Parses a raw header value from the wire. # - # @parameter value [String] the value of the header. + # @parameter value [String] the raw header value containing comma-separated media types. + # @returns [Accept] a new instance containing the parsed media types. + def self.parse(value) + self.new(value.scan(SEPARATOR).map(&:strip)) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String | Array] the value to coerce. + # @returns [Accept] a parsed header object. + def self.coerce(value) + case value + when Array + self.new(value) + else + self.parse(value.to_s) + end + end + + # Initializes an Accept header with already-parsed values. + # + # @parameter value [Array | Nil] an array of parsed media type strings, or `nil` for an empty header. def initialize(value = nil) - if value - super(value.scan(SEPARATOR).map(&:strip)) + if value.is_a?(Array) + super(value) + elsif value.is_a?(String) + # Compatibility with the old constructor, prefer to use `parse` instead: + super() + self << value + elsif value + raise ArgumentError, "Invalid value: #{value.inspect}" end end - # Adds one or more comma-separated values to the header. + # Adds one or more comma-separated values to the header from a raw wire-format string. # # The input string is split into distinct entries and appended to the array. # - # @parameter value [String] the value or values to add, separated by commas. + # @parameter value [String] a raw wire-format value containing one or more media types separated by commas. def << value self.concat(value.scan(SEPARATOR).map(&:strip)) end - # Serializes the stored values into a comma-separated string. + # Converts the parsed header value into a raw wire-format string. # - # @returns [String] the serialized representation of the header values. + # @returns [String] a raw wire-format value (comma-separated string) suitable for transmission. def to_s join(",") end diff --git a/lib/protocol/http/header/authorization.rb b/lib/protocol/http/header/authorization.rb index 2484bf20..f3fcc1de 100644 --- a/lib/protocol/http/header/authorization.rb +++ b/lib/protocol/http/header/authorization.rb @@ -15,6 +15,22 @@ module Header # # TODO Support other authorization mechanisms, e.g. bearer token. class Authorization < String + # Parses a raw header value from the wire. + # + # @parameter value [String] the raw header value. + # @returns [Authorization] a new instance. + def self.parse(value) + self.new(value) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String] the value to coerce. + # @returns [Authorization] a parsed header object. + def self.coerce(value) + self.new(value.to_s) + end + # Splits the header into the credentials. # # @returns [Tuple(String, String)] The username and password. diff --git a/lib/protocol/http/header/cache_control.rb b/lib/protocol/http/header/cache_control.rb index a84edb8d..cd78ef8a 100644 --- a/lib/protocol/http/header/cache_control.rb +++ b/lib/protocol/http/header/cache_control.rb @@ -44,16 +44,24 @@ class CacheControl < Split # The `proxy-revalidate` directive is similar to `must-revalidate` but applies only to shared caches. PROXY_REVALIDATE = "proxy-revalidate" - # Initializes the cache control header with the given value. The value is expected to be a comma-separated string of cache directives. + # Initializes the cache control header with already-parsed and normalized values. # - # @parameter value [String | Nil] the raw Cache-Control header value. + # @parameter value [Array | Nil] an array of normalized (lowercase) directives, or `nil` for an empty header. def initialize(value = nil) - super(value&.downcase) + if value.is_a?(Array) + super(value.map(&:downcase)) + elsif value.is_a?(String) + # Compatibility with the old constructor, prefer to use `parse` instead: + super() + self << value + elsif value + raise ArgumentError, "Invalid value: #{value.inspect}" + end end - # Adds a directive to the Cache-Control header. The value will be normalized to lowercase before being added. + # Adds a directive to the Cache-Control header from a raw wire-format string. The value will be normalized to lowercase before being added. # - # @parameter value [String] the directive to add. + # @parameter value [String] a raw wire-format directive to add. def << value super(value.downcase) end @@ -132,3 +140,4 @@ def find_integer_value(value_name) end end end + diff --git a/lib/protocol/http/header/connection.rb b/lib/protocol/http/header/connection.rb index 1c8a65d7..17597b76 100644 --- a/lib/protocol/http/header/connection.rb +++ b/lib/protocol/http/header/connection.rb @@ -22,16 +22,24 @@ class Connection < Split # The `upgrade` directive indicates that the connection should be upgraded to a different protocol, as specified in the `Upgrade` header. UPGRADE = "upgrade" - # Initializes the connection header with the given value. The value is expected to be a comma-separated string of directives. + # Initializes the connection header with already-parsed and normalized values. # - # @parameter value [String | Nil] the raw `connection` header value. + # @parameter value [Array | Nil] an array of normalized (lowercase) directives, or `nil` for an empty header. def initialize(value = nil) - super(value&.downcase) + if value.is_a?(Array) + super(value.map(&:downcase)) + elsif value.is_a?(String) + # Compatibility with the old constructor, prefer to use `parse` instead: + super() + self << value + elsif value + raise ArgumentError, "Invalid value: #{value.inspect}" + end end - # Adds a directive to the `connection` header. The value will be normalized to lowercase before being added. + # Adds a directive to the `connection` header from a raw wire-format string. The value will be normalized to lowercase before being added. # - # @parameter value [String] the directive to add. + # @parameter value [String] a raw wire-format directive to add. def << value super(value.downcase) end @@ -61,3 +69,4 @@ def self.trailer? end end end + diff --git a/lib/protocol/http/header/date.rb b/lib/protocol/http/header/date.rb index 3f399cd7..69a90934 100644 --- a/lib/protocol/http/header/date.rb +++ b/lib/protocol/http/header/date.rb @@ -12,9 +12,25 @@ module Header # # This header is typically included in HTTP responses and follows the format defined in RFC 9110. class Date < String - # Replaces the current value of the `date` header with the specified value. + # Parses a raw header value from the wire. # - # @parameter value [String] the new value for the `date` header. + # @parameter value [String] the raw header value. + # @returns [Date] a new instance. + def self.parse(value) + self.new(value) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String] the value to coerce. + # @returns [Date] a parsed header object. + def self.coerce(value) + self.new(value.to_s) + end + + # Replaces the current value of the `date` header with a raw wire-format string. + # + # @parameter value [String] a raw wire-format value for the `date` header. def << value replace(value) end diff --git a/lib/protocol/http/header/etag.rb b/lib/protocol/http/header/etag.rb index c4f86f96..aad66267 100644 --- a/lib/protocol/http/header/etag.rb +++ b/lib/protocol/http/header/etag.rb @@ -10,9 +10,25 @@ module Header # # The `etag` header provides a unique identifier for a specific version of a resource, typically used for cache validation or conditional requests. It can be either a strong or weak validator as defined in RFC 9110. class ETag < String - # Replaces the current value of the `etag` header with the specified value. + # Parses a raw header value from the wire. # - # @parameter value [String] the new value for the `etag` header. + # @parameter value [String] the raw header value. + # @returns [ETag] a new instance. + def self.parse(value) + self.new(value) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String] the value to coerce. + # @returns [ETag] a parsed header object. + def self.coerce(value) + self.new(value.to_s) + end + + # Replaces the current value of the `etag` header with a raw wire-format string. + # + # @parameter value [String] a raw wire-format value for the `etag` header. def << value replace(value) end diff --git a/lib/protocol/http/header/etags.rb b/lib/protocol/http/header/etags.rb index b1943cd0..b9dc9d91 100644 --- a/lib/protocol/http/header/etags.rb +++ b/lib/protocol/http/header/etags.rb @@ -52,7 +52,7 @@ def weak_match?(etag) wildcard? || self.include?(etag) || self.include?(opposite_tag(etag)) end - private + private # Converts a weak tag to its strong counterpart or vice versa. # diff --git a/lib/protocol/http/header/multiple.rb b/lib/protocol/http/header/multiple.rb index 77140de9..25f36d90 100644 --- a/lib/protocol/http/header/multiple.rb +++ b/lib/protocol/http/header/multiple.rb @@ -10,18 +10,47 @@ module Header # # This isn't a specific header but is used as a base for headers that store multiple values, such as cookies. The values are split and stored as an array internally, and serialized back to a newline-separated string when needed. class Multiple < Array - # Initializes the multiple header with the given value. As the header key-value pair can only contain one value, the value given here is added to the internal array, and subsequent values can be added using the `<<` operator. + # Parses a raw header value from the wire. # - # @parameter value [String] the raw header value. - def initialize(value) + # Multiple headers receive each value as a separate header entry on the wire, so this method takes a single string value and creates a new instance containing it. + # + # @parameter value [String] a single raw header value from the wire. + # @returns [Multiple] a new instance containing the parsed value. + def self.parse(value) + self.new([value]) + end + + # Coerces a value into a parsed header object. + # + # This method is used by the Headers class when setting values via `[]=` to convert application values into the appropriate policy type. + # + # @parameter value [String | Array] the value to coerce. + # @returns [Multiple] a parsed header object. + def self.coerce(value) + case value + when Array + self.new(value) + else + self.parse(value.to_s) + end + end + + # Initializes the multiple header with already-parsed values. + # + # @parameter value [Array | Nil] an array of header values, or `nil` for an empty header. + def initialize(value = nil) super() - self << value + if value + self.concat(value) + end end - # Serializes the stored values into a newline-separated string. + # Converts the parsed header value into a raw wire-format string. + # + # Multiple headers are transmitted as separate header entries on the wire, so this serializes to a newline-separated string for storage. # - # @returns [String] the serialized representation of the header values. + # @returns [String] a raw wire-format value (newline-separated string). def to_s join("\n") end diff --git a/lib/protocol/http/header/priority.rb b/lib/protocol/http/header/priority.rb index b4522a50..c8cbc416 100644 --- a/lib/protocol/http/header/priority.rb +++ b/lib/protocol/http/header/priority.rb @@ -12,16 +12,24 @@ module Header # # The `priority` header allows clients to express their preference for how resources should be prioritized by the server. It supports directives like `u=` to specify the urgency level of a request, and `i` to indicate whether a response can be delivered incrementally. The urgency levels range from 0 (highest priority) to 7 (lowest priority), while the `i` directive is a boolean flag. class Priority < Split - # Initialize the priority header with the given value. + # Initializes the priority header with already-parsed and normalized values. # - # @parameter value [String | Nil] the value of the priority header, if any. The value should be a comma-separated string of directives. + # @parameter value [Array | Nil] an array of normalized (lowercase) directives, or `nil` for an empty header. def initialize(value = nil) - super(value&.downcase) + if value.is_a?(Array) + super(value.map(&:downcase)) + elsif value.is_a?(String) + # Compatibility with the old constructor, prefer to use `parse` instead: + super() + self << value + elsif value + raise ArgumentError, "Invalid value: #{value.inspect}" + end end - # Add a value to the priority header. + # Add a value to the priority header from a raw wire-format string. # - # @parameter value [String] the directive to add to the header. + # @parameter value [String] a raw wire-format directive to add to the header. def << value super(value.downcase) end @@ -55,3 +63,4 @@ def incremental? end end end + diff --git a/lib/protocol/http/header/split.rb b/lib/protocol/http/header/split.rb index 2301b925..df57131a 100644 --- a/lib/protocol/http/header/split.rb +++ b/lib/protocol/http/header/split.rb @@ -13,29 +13,58 @@ class Split < Array # Regular expression used to split values on commas, with optional surrounding whitespace. COMMA = /\s*,\s*/ - # Initializes a `Split` header with the given value. If the value is provided, it is split into distinct entries and stored as an array. + # Parses a raw header value from the wire. # - # @parameter value [String | Nil] the raw header value containing multiple entries separated by commas, or `nil` for an empty header. - def initialize(value = nil) - if value - super(value.split(COMMA)) + # Split headers receive comma-separated values in a single header entry on the wire. This method splits the raw value into individual entries. + # + # @parameter value [String] the raw header value containing multiple entries separated by commas. + # @returns [Split] a new instance containing the parsed values. + def self.parse(value) + self.new(value.split(COMMA)) + end + + # Coerces a value into a parsed header object. + # + # This method is used by the Headers class when setting values via `[]=` to convert application values into the appropriate policy type. + # + # @parameter value [String | Array] the value to coerce. + # @returns [Split] a parsed header object. + def self.coerce(value) + case value + when Array + self.new(value) else + self.parse(value.to_s) + end + end + + # Initializes a `Split` header with already-parsed values. + # + # @parameter value [Array | Nil] an array of parsed header values, or `nil` for an empty header. + def initialize(value = nil) + if value.is_a?(Array) + super(value) + elsif value.is_a?(String) + # Compatibility with the old constructor, prefer to use `parse` instead: super() + self << value + elsif value + raise ArgumentError, "Invalid value: #{value.inspect}" end end - # Adds one or more comma-separated values to the header. + # Adds one or more comma-separated values to the header from a raw wire-format string. # # The input string is split into distinct entries and appended to the array. # - # @parameter value [String] the value or values to add, separated by commas. + # @parameter value [String] a raw wire-format value containing one or more values separated by commas. def << value self.concat(value.split(COMMA)) end - # Serializes the stored values into a comma-separated string. + # Converts the parsed header value into a raw wire-format string. # - # @returns [String] the serialized representation of the header values. + # @returns [String] a raw wire-format value (comma-separated string) suitable for transmission. def to_s join(",") end @@ -47,7 +76,7 @@ def self.trailer? false end - protected + protected def reverse_find(&block) reverse_each do |value| diff --git a/lib/protocol/http/header/te.rb b/lib/protocol/http/header/te.rb index e331db13..03f49a07 100644 --- a/lib/protocol/http/header/te.rb +++ b/lib/protocol/http/header/te.rb @@ -62,16 +62,24 @@ def to_s end end - # Initializes the TE header with the given value. The value is split into distinct entries and converted to lowercase for normalization. + # Initializes the TE header with already-parsed and normalized values. # - # @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas. + # @parameter value [Array | Nil] an array of normalized (lowercase) encodings, or `nil` for an empty header. def initialize(value = nil) - super(value&.downcase) + if value.is_a?(Array) + super(value.map(&:downcase)) + elsif value.is_a?(String) + # Compatibility with the old constructor, prefer to use `parse` instead: + super() + self << value + elsif value + raise ArgumentError, "Invalid value: #{value.inspect}" + end end - # Adds one or more comma-separated values to the TE header. The values are converted to lowercase for normalization. + # Adds one or more comma-separated values to the TE header from a raw wire-format string. The values are converted to lowercase for normalization. # - # @parameter value [String] the value or values to add, separated by commas. + # @parameter value [String] a raw wire-format value containing one or more values separated by commas. def << value super(value.downcase) end @@ -129,3 +137,4 @@ def self.trailer? end end end + diff --git a/lib/protocol/http/header/transfer_encoding.rb b/lib/protocol/http/header/transfer_encoding.rb index 354dce6a..bade69d8 100644 --- a/lib/protocol/http/header/transfer_encoding.rb +++ b/lib/protocol/http/header/transfer_encoding.rb @@ -27,16 +27,24 @@ class TransferEncoding < Split # The `identity` transfer encoding indicates no transformation has been applied. IDENTITY = "identity" - # Initializes the transfer encoding header with the given value. The value is split into distinct entries and converted to lowercase for normalization. + # Initializes the transfer encoding header with already-parsed and normalized values. # - # @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas. + # @parameter value [Array | Nil] an array of normalized (lowercase) encodings, or `nil` for an empty header. def initialize(value = nil) - super(value&.downcase) + if value.is_a?(Array) + super(value.map(&:downcase)) + elsif value.is_a?(String) + # Compatibility with the old constructor, prefer to use `parse` instead: + super() + self << value + elsif value + raise ArgumentError, "Invalid value: #{value.inspect}" + end end - # Adds one or more comma-separated values to the transfer encoding header. The values are converted to lowercase for normalization. + # Adds one or more comma-separated values to the transfer encoding header from a raw wire-format string. The values are converted to lowercase for normalization. # - # @parameter value [String] the value or values to add, separated by commas. + # @parameter value [String] a raw wire-format value containing one or more values separated by commas. def << value super(value.downcase) end @@ -76,3 +84,4 @@ def self.trailer? end end end + diff --git a/lib/protocol/http/header/vary.rb b/lib/protocol/http/header/vary.rb index 3fc5a217..7f0a74fc 100644 --- a/lib/protocol/http/header/vary.rb +++ b/lib/protocol/http/header/vary.rb @@ -12,16 +12,24 @@ module Header # # The `vary` header is used in HTTP responses to indicate which request headers affect the selected response. It allows caches to differentiate stored responses based on specific request headers. class Vary < Split - # Initializes a `Vary` header with the given value. The value is split into distinct entries and converted to lowercase for normalization. + # Initializes a `Vary` header with already-parsed and normalized values. # - # @parameter value [String] the raw header value containing request header names separated by commas. - def initialize(value) - super(value.downcase) + # @parameter value [Array | Nil] an array of normalized (lowercase) header names, or `nil` for an empty header. + def initialize(value = nil) + if value.is_a?(Array) + super(value.map(&:downcase)) + elsif value.is_a?(String) + # Compatibility with the old constructor, prefer to use `parse` instead: + super() + self << value + elsif value + raise ArgumentError, "Invalid value: #{value.inspect}" + end end - # Adds one or more comma-separated values to the `vary` header. The values are converted to lowercase for normalization. + # Adds one or more comma-separated values to the `vary` header from a raw wire-format string. The values are converted to lowercase for normalization. # - # @parameter value [String] the value or values to add, separated by commas. + # @parameter value [String] a raw wire-format value containing one or more values separated by commas. def << value super(value.downcase) end @@ -29,3 +37,4 @@ def << value end end end + diff --git a/lib/protocol/http/headers.rb b/lib/protocol/http/headers.rb index b3498f9c..0db5e12a 100644 --- a/lib/protocol/http/headers.rb +++ b/lib/protocol/http/headers.rb @@ -189,7 +189,7 @@ def empty? # # @yields {|key, value| ...} # @parameter key [String] The header key. - # @parameter value [String] The header value. + # @parameter value [String] The raw header value. def each(&block) @fields.each(&block) end @@ -228,7 +228,6 @@ def extract(keys) # @parameter key [String] the header key. # @parameter value [String] the header value to assign. def add(key, value) - # The value MUST be a string, so we convert it to a string to prevent errors later on. value = value.to_s if @indexed @@ -238,18 +237,51 @@ def add(key, value) @fields << [key, value] end - alias []= add - # Set the specified header key to the specified value, replacing any existing header keys with the same name. # # @parameter key [String] the header key to replace. # @parameter value [String] the header value to assign. def set(key, value) - # TODO This could be a bit more efficient: self.delete(key) self.add(key, value) end + # Set the specified header key to the specified value, replacing any existing values. + # + # The value can be a String or a coercable value. + # + # @parameter key [String] the header key. + # @parameter value [String | Array] the header value to assign. + def []=(key, value) + key = key.downcase + + # Delete existing value if any: + self.delete(key) + + if policy = @policy[key] + unless value.is_a?(policy) + value = policy.coerce(value) + end + else + value = value.to_s + end + + # Clear the indexed cache so it will be rebuilt with parsed values when accessed: + if @indexed + @indexed[key] = value + end + + @fields << [key, value.to_s] + end + + # Get the value of the specified header key. + # + # @parameter key [String] The header key. + # @returns [String | Array | Object] The header value. + def [] key + to_h[key] + end + # Merge the headers into this instance. def merge!(headers) headers.each do |key, value| @@ -338,6 +370,11 @@ def merge(headers) # @parameter key [String] The header key. # @returns [String | Array | Object] The merged header value. def delete(key) + # If we've indexed the headers, we can bail out early if the key is not present: + if @indexed && !@indexed.key?(key.downcase) + return nil + end + deleted, @fields = @fields.partition do |field| field.first.downcase == key end @@ -350,7 +387,7 @@ def delete(key) return @indexed.delete(key) elsif policy = @policy[key] (key, value), *tail = deleted - merged = policy.new(value) + merged = policy.parse(value) tail.each{|k,v| merged << v} @@ -376,7 +413,11 @@ def delete(key) if current_value = hash[key] current_value << value else - hash[key] = policy.new(value) + if policy.respond_to?(:parse) + hash[key] = policy.parse(value) + else + hash[key] = policy.new(value) + end end else # By default, headers are not allowed in trailers: @@ -392,14 +433,6 @@ def delete(key) end end - # Get the value of the specified header key. - # - # @parameter key [String] The header key. - # @returns [String | Array | Object] The header value. - def [] key - to_h[key] - end - # Compute a hash table of headers, where the keys are normalized to lower case and the values are normalized according to the policy for that header. # # @returns [Hash] A hash table of `{key, value}` pairs. diff --git a/releases.md b/releases.md index c3cb1011..e7e209a1 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,12 @@ # Releases +## Unreleased + + - Introduce `Header::*.parse(value)` which converts a string representation to a header instance. + - Introduce `Header::*.coerce(value)` which converts a rich representation (e.g. `Array`) to a header instance. + - `Header::*#initialize` still implements parse-like behaviour, but it's considered deprecated. + - Update `Headers#[]=` to use `parse(value)` for conversion. This provides better symmetry with `Headers#[]`. + ## v0.55.0 - **Breaking**: Move `Protocol::HTTP::Header::QuotedString` to `Protocol::HTTP::QuotedString` for better reusability. diff --git a/test/protocol/http/header/accept.rb b/test/protocol/http/header/accept.rb index 776a8853..7e318ca6 100644 --- a/test/protocol/http/header/accept.rb +++ b/test/protocol/http/header/accept.rb @@ -20,7 +20,7 @@ end describe Protocol::HTTP::Header::Accept do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} let(:media_ranges) {header.media_ranges.sort} with "text/plain, text/html;q=0.5, text/xml;q=0.25" do diff --git a/test/protocol/http/header/accept_charset.rb b/test/protocol/http/header/accept_charset.rb index 93f698ee..5d4ef95c 100644 --- a/test/protocol/http/header/accept_charset.rb +++ b/test/protocol/http/header/accept_charset.rb @@ -13,7 +13,7 @@ end describe Protocol::HTTP::Header::AcceptCharset do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} let(:charsets) {header.charsets.sort} with "utf-8, iso-8859-1;q=0.5, windows-1252;q=0.25" do @@ -74,7 +74,7 @@ ] bad_values.each do |value| - expect{subject.new(value).charsets}.to raise_exception(subject::ParseError) + expect{subject.parse(value).charsets}.to raise_exception(subject::ParseError) end end end diff --git a/test/protocol/http/header/accept_encoding.rb b/test/protocol/http/header/accept_encoding.rb index 647a1e9e..5c666dca 100644 --- a/test/protocol/http/header/accept_encoding.rb +++ b/test/protocol/http/header/accept_encoding.rb @@ -13,7 +13,7 @@ end describe Protocol::HTTP::Header::AcceptEncoding do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} let(:encodings) {header.encodings.sort} with "gzip, deflate;q=0.5, identity;q=0.25" do @@ -74,7 +74,7 @@ ] bad_values.each do |value| - expect{subject.new(value).encodings}.to raise_exception(subject::ParseError) + expect{subject.parse(value).encodings}.to raise_exception(subject::ParseError) end end end diff --git a/test/protocol/http/header/accept_language.rb b/test/protocol/http/header/accept_language.rb index caefa07b..43bf7c5a 100644 --- a/test/protocol/http/header/accept_language.rb +++ b/test/protocol/http/header/accept_language.rb @@ -13,7 +13,7 @@ end describe Protocol::HTTP::Header::AcceptLanguage do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} let(:languages) {header.languages.sort} with "da, en-gb;q=0.5, en;q=0.25" do @@ -89,7 +89,7 @@ ] bad_values.each do |value| - expect{subject.new(value).languages}.to raise_exception(subject::ParseError) + expect{subject.parse(value).languages}.to raise_exception(subject::ParseError) end end end diff --git a/test/protocol/http/header/cache_control.rb b/test/protocol/http/header/cache_control.rb index 5246b45a..ba082660 100644 --- a/test/protocol/http/header/cache_control.rb +++ b/test/protocol/http/header/cache_control.rb @@ -7,7 +7,7 @@ require "protocol/http/header/cache_control" describe Protocol::HTTP::Header::CacheControl do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "max-age=60, s-maxage=30, public" do it "correctly parses cache header" do @@ -88,4 +88,21 @@ ) end end + + with "normalization" do + it "normalizes to lowercase when initialized with string" do + header = subject.new("PUBLIC, MAX-AGE=60") + expect(header).to be(:include?, "public") + expect(header).to be(:include?, "max-age=60") + expect(header).not.to be(:include?, "PUBLIC") + end + + it "normalizes to lowercase when initialized with array" do + header = subject.new(["PUBLIC", "NO-CACHE"]) + expect(header).to be(:include?, "public") + expect(header).to be(:include?, "no-cache") + expect(header).not.to be(:include?, "PUBLIC") + expect(header).not.to be(:include?, "NO-CACHE") + end + end end diff --git a/test/protocol/http/header/connection.rb b/test/protocol/http/header/connection.rb index 5e8a0f9a..b6154873 100644 --- a/test/protocol/http/header/connection.rb +++ b/test/protocol/http/header/connection.rb @@ -8,7 +8,7 @@ require "protocol/http/cookie" describe Protocol::HTTP::Header::Connection do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "close" do it "should indiciate connection will be closed" do @@ -56,4 +56,22 @@ expect(header.to_s).to be == "close,upgrade" end end + + with "normalization" do + it "normalizes to lowercase when initialized with string" do + header = subject.new("CLOSE, UPGRADE") + expect(header).to be(:include?, "close") + expect(header).to be(:include?, "upgrade") + expect(header).not.to be(:include?, "CLOSE") + expect(header).not.to be(:include?, "UPGRADE") + end + + it "normalizes to lowercase when initialized with array" do + header = subject.new(["CLOSE", "UPGRADE"]) + expect(header).to be(:include?, "close") + expect(header).to be(:include?, "upgrade") + expect(header).not.to be(:include?, "CLOSE") + expect(header).not.to be(:include?, "UPGRADE") + end + end end diff --git a/test/protocol/http/header/cookie.rb b/test/protocol/http/header/cookie.rb index b560df0d..f55b631f 100644 --- a/test/protocol/http/header/cookie.rb +++ b/test/protocol/http/header/cookie.rb @@ -7,7 +7,7 @@ require "protocol/http/header/cookie" describe Protocol::HTTP::Header::Cookie do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} let(:cookies) {header.to_h} with "session=123; secure" do diff --git a/test/protocol/http/header/date.rb b/test/protocol/http/header/date.rb index a19b5c51..ca8dd837 100644 --- a/test/protocol/http/header/date.rb +++ b/test/protocol/http/header/date.rb @@ -6,7 +6,7 @@ require "protocol/http/header/date" describe Protocol::HTTP::Header::Date do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "Wed, 21 Oct 2015 07:28:00 GMT" do it "can parse time" do diff --git a/test/protocol/http/header/digest.rb b/test/protocol/http/header/digest.rb index f49d168a..9e2b561b 100644 --- a/test/protocol/http/header/digest.rb +++ b/test/protocol/http/header/digest.rb @@ -7,7 +7,7 @@ require "sus" describe Protocol::HTTP::Header::Digest do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "empty header" do let(:header) {subject.new} @@ -120,13 +120,13 @@ with "algorithm edge cases" do it "handles hyphenated algorithms" do - header = subject.new("sha-256=abc123") + header = subject.parse("sha-256=abc123") entries = header.entries expect(entries.first.algorithm).to be == "sha-256" end it "handles numeric algorithms" do - header = subject.new("md5=def456") + header = subject.parse("md5=def456") entries = header.entries expect(entries.first.algorithm).to be == "md5" end @@ -134,13 +134,13 @@ with "value edge cases" do it "handles empty values" do - header = subject.new("sha-256=") + header = subject.parse("sha-256=") entries = header.entries expect(entries.first.value).to be == "" end it "handles values with special characters" do - header = subject.new("sha-256=abc+def/123==") + header = subject.parse("sha-256=abc+def/123==") entries = header.entries expect(entries.first.value).to be == "abc+def/123==" end diff --git a/test/protocol/http/header/etag.rb b/test/protocol/http/header/etag.rb index 083b7451..877ea064 100644 --- a/test/protocol/http/header/etag.rb +++ b/test/protocol/http/header/etag.rb @@ -6,7 +6,7 @@ require "protocol/http/header/etag" describe Protocol::HTTP::Header::ETag do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with 'W/"abcd"' do it "is weak" do diff --git a/test/protocol/http/header/etags.rb b/test/protocol/http/header/etags.rb index d2a1e083..d5ffc6c8 100644 --- a/test/protocol/http/header/etags.rb +++ b/test/protocol/http/header/etags.rb @@ -7,7 +7,7 @@ require "protocol/http/header/etags" describe Protocol::HTTP::Header::ETags do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "*" do it "is a wildcard" do diff --git a/test/protocol/http/header/multiple.rb b/test/protocol/http/header/multiple.rb index 48479f06..5fa1e15e 100644 --- a/test/protocol/http/header/multiple.rb +++ b/test/protocol/http/header/multiple.rb @@ -6,7 +6,7 @@ require "protocol/http/header/multiple" describe Protocol::HTTP::Header::Multiple do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "first-value" do it "can add several values" do diff --git a/test/protocol/http/header/priority.rb b/test/protocol/http/header/priority.rb index 0346cc89..5bcbc52a 100644 --- a/test/protocol/http/header/priority.rb +++ b/test/protocol/http/header/priority.rb @@ -6,7 +6,7 @@ require "protocol/http/header/priority" describe Protocol::HTTP::Header::Priority do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "u=1, i" do it "correctly parses priority header" do @@ -79,4 +79,22 @@ ) end end + + with "normalization" do + it "normalizes to lowercase when initialized with string" do + header = subject.new("U=5, I") + expect(header).to be(:include?, "u=5") + expect(header).to be(:include?, "i") + expect(header).not.to be(:include?, "U=5") + expect(header).not.to be(:include?, "I") + end + + it "normalizes to lowercase when initialized with array" do + header = subject.new(["U=3", "I"]) + expect(header).to be(:include?, "u=3") + expect(header).to be(:include?, "i") + expect(header).not.to be(:include?, "U=3") + expect(header).not.to be(:include?, "I") + end + end end diff --git a/test/protocol/http/header/server_timing.rb b/test/protocol/http/header/server_timing.rb index bf5145e9..a9358385 100644 --- a/test/protocol/http/header/server_timing.rb +++ b/test/protocol/http/header/server_timing.rb @@ -7,7 +7,7 @@ require "sus" describe Protocol::HTTP::Header::ServerTiming do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "empty header" do let(:header) {subject.new} diff --git a/test/protocol/http/header/te.rb b/test/protocol/http/header/te.rb index d2e749b4..d442bd16 100644 --- a/test/protocol/http/header/te.rb +++ b/test/protocol/http/header/te.rb @@ -6,7 +6,7 @@ require "protocol/http/header/te" describe Protocol::HTTP::Header::TE do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "chunked" do it "detects chunked encoding" do @@ -91,7 +91,7 @@ with "error handling" do it "raises ParseError for invalid transfer coding" do - header = subject.new("invalid@encoding") + header = subject.parse("invalid@encoding") expect do header.transfer_codings end.to raise_exception(Protocol::HTTP::Header::TE::ParseError) @@ -103,6 +103,24 @@ expect(subject).not.to be(:trailer?) end end + + with "normalization" do + it "normalizes to lowercase when initialized with string" do + header = subject.new("GZIP, CHUNKED") + expect(header).to be(:include?, "gzip") + expect(header).to be(:include?, "chunked") + expect(header).not.to be(:include?, "GZIP") + expect(header).not.to be(:include?, "CHUNKED") + end + + it "normalizes to lowercase when initialized with array" do + header = subject.new(["GZIP", "CHUNKED"]) + expect(header).to be(:include?, "gzip") + expect(header).to be(:include?, "chunked") + expect(header).not.to be(:include?, "GZIP") + expect(header).not.to be(:include?, "CHUNKED") + end + end end describe Protocol::HTTP::Header::TE::TransferCoding do diff --git a/test/protocol/http/header/trailer.rb b/test/protocol/http/header/trailer.rb index 9250223c..bfbb0fe8 100644 --- a/test/protocol/http/header/trailer.rb +++ b/test/protocol/http/header/trailer.rb @@ -6,7 +6,7 @@ require "protocol/http/header/trailer" describe Protocol::HTTP::Header::Trailer do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "etag" do it "contains etag header" do @@ -59,7 +59,7 @@ end with "#<<" do - let(:header) {subject.new("etag")} + let(:header) {subject.parse("etag")} it "can add headers" do header << "content-md5, expires" diff --git a/test/protocol/http/header/transfer_encoding.rb b/test/protocol/http/header/transfer_encoding.rb index 2cfd7e30..ed317825 100644 --- a/test/protocol/http/header/transfer_encoding.rb +++ b/test/protocol/http/header/transfer_encoding.rb @@ -6,7 +6,7 @@ require "protocol/http/header/transfer_encoding" describe Protocol::HTTP::Header::TransferEncoding do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "chunked" do it "detects chunked encoding" do @@ -74,4 +74,22 @@ expect(subject).not.to be(:trailer?) end end + + with "normalization" do + it "normalizes to lowercase when initialized with string" do + header = subject.new("GZIP, CHUNKED") + expect(header).to be(:include?, "gzip") + expect(header).to be(:include?, "chunked") + expect(header).not.to be(:include?, "GZIP") + expect(header).not.to be(:include?, "CHUNKED") + end + + it "normalizes to lowercase when initialized with array" do + header = subject.new(["GZIP", "CHUNKED"]) + expect(header).to be(:include?, "gzip") + expect(header).to be(:include?, "chunked") + expect(header).not.to be(:include?, "GZIP") + expect(header).not.to be(:include?, "CHUNKED") + end + end end diff --git a/test/protocol/http/header/vary.rb b/test/protocol/http/header/vary.rb index fce26c49..7c2c3dd1 100644 --- a/test/protocol/http/header/vary.rb +++ b/test/protocol/http/header/vary.rb @@ -6,7 +6,7 @@ require "protocol/http/header/vary" describe Protocol::HTTP::Header::Vary do - let(:header) {subject.new(description)} + let(:header) {subject.parse(description)} with "#<<" do it "can append normalised header names" do @@ -34,4 +34,14 @@ expect(header).not.to be(:include?, "Accept-Language") end end + + with "normalization" do + it "normalizes to lowercase when initialized with array" do + header = subject.new(["Accept-Language", "User-Agent"]) + expect(header).to be(:include?, "accept-language") + expect(header).to be(:include?, "user-agent") + expect(header).not.to be(:include?, "Accept-Language") + expect(header).not.to be(:include?, "User-Agent") + end + end end diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index bfaca514..f75d0579 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -195,14 +195,14 @@ it "can add field with a String value" do headers["Content-Length"] = "1" - expect(headers.fields.last).to be == ["Content-Length", "1"] + expect(headers.fields.last).to be == ["content-length", "1"] expect(headers["content-length"]).to be == "1" end it "can add field with an Integer value" do headers["Content-Length"] = 1 - expect(headers.fields.last).to be == ["Content-Length", "1"] + expect(headers.fields.last).to be == ["content-length", "1"] expect(headers["content-length"]).to be == "1" end From 0d9d06c8c3322ad9f640c4900000457e5ddb34f9 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 13 Dec 2025 00:41:23 +1300 Subject: [PATCH 2/8] 100% test coverage. --- test/protocol/http/header/accept.rb | 26 +++++++++++++++++++ test/protocol/http/header/authorization.rb | 16 ++++++++++++ test/protocol/http/header/cache_control.rb | 4 +++ test/protocol/http/header/connection.rb | 4 +++ test/protocol/http/header/date.rb | 8 ++++++ test/protocol/http/header/etag.rb | 8 ++++++ test/protocol/http/header/multiple.rb | 14 ++++++++++ test/protocol/http/header/priority.rb | 4 +++ test/protocol/http/header/te.rb | 4 +++ test/protocol/http/header/trailer.rb | 12 +++++++++ .../protocol/http/header/transfer_encoding.rb | 4 +++ test/protocol/http/header/vary.rb | 10 +++++++ test/protocol/http/headers.rb | 6 +++++ 13 files changed, 120 insertions(+) diff --git a/test/protocol/http/header/accept.rb b/test/protocol/http/header/accept.rb index 7e318ca6..2a087a7c 100644 --- a/test/protocol/http/header/accept.rb +++ b/test/protocol/http/header/accept.rb @@ -83,4 +83,30 @@ ) end end + + with ".coerce" do + it "coerces array to Accept" do + result = subject.coerce(["text/html", "application/json"]) + expect(result).to be_a(subject) + expect(result).to be == ["text/html", "application/json"] + end + + it "coerces string to Accept" do + result = subject.coerce("text/html, application/json") + expect(result).to be_a(subject) + expect(result).to be(:include?, "text/html") + end + end + + with "backward compatibility" do + it "can initialize with string" do + header = subject.new("text/plain, text/html") + expect(header).to be(:include?, "text/plain") + expect(header).to be(:include?, "text/html") + end + + it "raises ArgumentError for invalid value types" do + expect{subject.new(123)}.to raise_exception(ArgumentError) + end + end end diff --git a/test/protocol/http/header/authorization.rb b/test/protocol/http/header/authorization.rb index 10e63a1b..27ec841b 100644 --- a/test/protocol/http/header/authorization.rb +++ b/test/protocol/http/header/authorization.rb @@ -20,4 +20,20 @@ end end end + + with ".parse" do + it "parses raw authorization value" do + result = subject.parse("Bearer token123") + expect(result).to be_a(subject) + expect(result).to be == "Bearer token123" + end + end + + with ".coerce" do + it "coerces string to Authorization" do + result = subject.coerce("Bearer xyz") + expect(result).to be_a(subject) + expect(result).to be == "Bearer xyz" + end + end end diff --git a/test/protocol/http/header/cache_control.rb b/test/protocol/http/header/cache_control.rb index ba082660..bb6d0f77 100644 --- a/test/protocol/http/header/cache_control.rb +++ b/test/protocol/http/header/cache_control.rb @@ -104,5 +104,9 @@ expect(header).not.to be(:include?, "PUBLIC") expect(header).not.to be(:include?, "NO-CACHE") end + + it "raises ArgumentError for invalid value types" do + expect{subject.new(123)}.to raise_exception(ArgumentError) + end end end diff --git a/test/protocol/http/header/connection.rb b/test/protocol/http/header/connection.rb index b6154873..19a2ad7f 100644 --- a/test/protocol/http/header/connection.rb +++ b/test/protocol/http/header/connection.rb @@ -73,5 +73,9 @@ expect(header).not.to be(:include?, "CLOSE") expect(header).not.to be(:include?, "UPGRADE") end + + it "raises ArgumentError for invalid value types" do + expect{subject.new(123)}.to raise_exception(ArgumentError) + end end end diff --git a/test/protocol/http/header/date.rb b/test/protocol/http/header/date.rb index ca8dd837..6ed8b769 100644 --- a/test/protocol/http/header/date.rb +++ b/test/protocol/http/header/date.rb @@ -44,6 +44,14 @@ end end + with ".coerce" do + it "coerces string to Date" do + result = subject.coerce("Wed, 21 Oct 2015 07:28:00 GMT") + expect(result).to be_a(subject) + expect(result.to_time.year).to be == 2015 + end + end + describe Protocol::HTTP::Headers do let(:headers) {subject[[ ["Date", "Wed, 21 Oct 2015 07:28:00 GMT"], diff --git a/test/protocol/http/header/etag.rb b/test/protocol/http/header/etag.rb index 877ea064..9a342054 100644 --- a/test/protocol/http/header/etag.rb +++ b/test/protocol/http/header/etag.rb @@ -31,4 +31,12 @@ expect(header).to be(:weak?) end end + + with ".coerce" do + it "coerces string to ETag" do + result = subject.coerce('"xyz"') + expect(result).to be_a(subject) + expect(result).to be == '"xyz"' + end + end end diff --git a/test/protocol/http/header/multiple.rb b/test/protocol/http/header/multiple.rb index 5fa1e15e..0a787a12 100644 --- a/test/protocol/http/header/multiple.rb +++ b/test/protocol/http/header/multiple.rb @@ -25,4 +25,18 @@ expect(subject).not.to be(:trailer?) end end + + with ".coerce" do + it "coerces array to Multiple" do + result = subject.coerce(["value1", "value2"]) + expect(result).to be_a(subject) + expect(result).to be == ["value1", "value2"] + end + + it "coerces string to Multiple" do + result = subject.coerce("single-value") + expect(result).to be_a(subject) + expect(result).to be == ["single-value"] + end + end end diff --git a/test/protocol/http/header/priority.rb b/test/protocol/http/header/priority.rb index 5bcbc52a..c0b4bfc5 100644 --- a/test/protocol/http/header/priority.rb +++ b/test/protocol/http/header/priority.rb @@ -96,5 +96,9 @@ expect(header).not.to be(:include?, "U=3") expect(header).not.to be(:include?, "I") end + + it "raises ArgumentError for invalid value types" do + expect{subject.new(123)}.to raise_exception(ArgumentError) + end end end diff --git a/test/protocol/http/header/te.rb b/test/protocol/http/header/te.rb index d442bd16..c31fb2af 100644 --- a/test/protocol/http/header/te.rb +++ b/test/protocol/http/header/te.rb @@ -120,6 +120,10 @@ expect(header).not.to be(:include?, "GZIP") expect(header).not.to be(:include?, "CHUNKED") end + + it "raises ArgumentError for invalid value types" do + expect{subject.new(123)}.to raise_exception(ArgumentError) + end end end diff --git a/test/protocol/http/header/trailer.rb b/test/protocol/http/header/trailer.rb index bfbb0fe8..753849fb 100644 --- a/test/protocol/http/header/trailer.rb +++ b/test/protocol/http/header/trailer.rb @@ -73,4 +73,16 @@ expect(subject).not.to be(:trailer?) end end + + with "backward compatibility" do + it "can initialize with string for backward compatibility" do + header = subject.new("etag, content-md5") + expect(header).to be(:include?, "etag") + expect(header).to be(:include?, "content-md5") + end + + it "raises ArgumentError for invalid value types" do + expect{subject.new(123)}.to raise_exception(ArgumentError) + end + end end diff --git a/test/protocol/http/header/transfer_encoding.rb b/test/protocol/http/header/transfer_encoding.rb index ed317825..56074a7d 100644 --- a/test/protocol/http/header/transfer_encoding.rb +++ b/test/protocol/http/header/transfer_encoding.rb @@ -91,5 +91,9 @@ expect(header).not.to be(:include?, "GZIP") expect(header).not.to be(:include?, "CHUNKED") end + + it "raises ArgumentError for invalid value types" do + expect{subject.new(123)}.to raise_exception(ArgumentError) + end end end diff --git a/test/protocol/http/header/vary.rb b/test/protocol/http/header/vary.rb index 7c2c3dd1..f0933933 100644 --- a/test/protocol/http/header/vary.rb +++ b/test/protocol/http/header/vary.rb @@ -43,5 +43,15 @@ expect(header).not.to be(:include?, "Accept-Language") expect(header).not.to be(:include?, "User-Agent") end + + it "can initialize with string for backward compatibility" do + header = subject.new("Accept-Language, User-Agent") + expect(header).to be(:include?, "accept-language") + expect(header).to be(:include?, "user-agent") + end + + it "raises ArgumentError for invalid value types" do + expect{subject.new(123)}.to raise_exception(ArgumentError) + end end end diff --git a/test/protocol/http/headers.rb b/test/protocol/http/headers.rb index f75d0579..517a49fb 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -206,6 +206,12 @@ expect(headers["content-length"]).to be == "1" end + it "can add field with an Array value" do + headers["accept-encoding"] = ["gzip", "deflate"] + expect(headers["accept-encoding"]).to be(:include?, "gzip") + expect(headers["accept-encoding"]).to be(:include?, "deflate") + end + it "can add field with indexed hash" do expect(headers.to_h).not.to be(:empty?) From 0cc3f9d89fe8d32a881c543d9402c8d441c5a8b9 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 13 Dec 2025 00:47:30 +1300 Subject: [PATCH 3/8] Formatting. --- lib/protocol/http/body/streamable.rb | 8 ++++---- releases.md | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/protocol/http/body/streamable.rb b/lib/protocol/http/body/streamable.rb index 7b722569..20e95af3 100644 --- a/lib/protocol/http/body/streamable.rb +++ b/lib/protocol/http/body/streamable.rb @@ -127,10 +127,10 @@ def call(stream) # Ownership of the stream is passed into the block, in other words, the block is responsible for closing the stream. block.call(stream) - rescue => error - # If, for some reason, the block raises an error, we assume it may not have closed the stream, so we close it here: - stream.close - raise + rescue => error + # If, for some reason, the block raises an error, we assume it may not have closed the stream, so we close it here: + stream.close + raise end # Close the input. The streaming body will eventually read all the input. diff --git a/releases.md b/releases.md index e7e209a1..e3c552d6 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,7 @@ ## Unreleased - - Introduce `Header::*.parse(value)` which converts a string representation to a header instance. + - Introduce `Header::*.parse(value)` which converts a string representation to a header instance. - Introduce `Header::*.coerce(value)` which converts a rich representation (e.g. `Array`) to a header instance. - `Header::*#initialize` still implements parse-like behaviour, but it's considered deprecated. - Update `Headers#[]=` to use `parse(value)` for conversion. This provides better symmetry with `Headers#[]`. From 8b2afaf76c1f1bad972accfec724f6fe1eed4b47 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 13 Dec 2025 10:35:19 +1300 Subject: [PATCH 4/8] Minor tidy up. --- lib/protocol/http/header/accept.rb | 8 +++----- lib/protocol/http/header/connection.rb | 3 +-- lib/protocol/http/header/multiple.rb | 2 +- lib/protocol/http/header/te.rb | 3 +-- lib/protocol/http/header/transfer_encoding.rb | 3 +-- lib/protocol/http/header/vary.rb | 3 +-- 6 files changed, 8 insertions(+), 14 deletions(-) diff --git a/lib/protocol/http/header/accept.rb b/lib/protocol/http/header/accept.rb index 01781c8b..96a13878 100644 --- a/lib/protocol/http/header/accept.rb +++ b/lib/protocol/http/header/accept.rb @@ -12,7 +12,7 @@ module Protocol module HTTP module Header # The `accept-content-type` header represents a list of content-types that the client can accept. - class Accept < Array + class Accept < Split # Regular expression used to split values on commas, with optional surrounding whitespace, taking into account quoted strings. SEPARATOR = / (?: # Start non-capturing group @@ -83,7 +83,7 @@ def self.parse(value) def self.coerce(value) case value when Array - self.new(value) + self.new(value.map(&:to_s)) else self.parse(value.to_s) end @@ -96,9 +96,7 @@ def initialize(value = nil) if value.is_a?(Array) super(value) elsif value.is_a?(String) - # Compatibility with the old constructor, prefer to use `parse` instead: - super() - self << value + super(value) elsif value raise ArgumentError, "Invalid value: #{value.inspect}" end diff --git a/lib/protocol/http/header/connection.rb b/lib/protocol/http/header/connection.rb index 17597b76..3bd9099b 100644 --- a/lib/protocol/http/header/connection.rb +++ b/lib/protocol/http/header/connection.rb @@ -30,8 +30,7 @@ def initialize(value = nil) super(value.map(&:downcase)) elsif value.is_a?(String) # Compatibility with the old constructor, prefer to use `parse` instead: - super() - self << value + super(value) elsif value raise ArgumentError, "Invalid value: #{value.inspect}" end diff --git a/lib/protocol/http/header/multiple.rb b/lib/protocol/http/header/multiple.rb index 25f36d90..64fe4a08 100644 --- a/lib/protocol/http/header/multiple.rb +++ b/lib/protocol/http/header/multiple.rb @@ -29,7 +29,7 @@ def self.parse(value) def self.coerce(value) case value when Array - self.new(value) + self.new(value.map(&:to_s)) else self.parse(value.to_s) end diff --git a/lib/protocol/http/header/te.rb b/lib/protocol/http/header/te.rb index 03f49a07..5b8558a6 100644 --- a/lib/protocol/http/header/te.rb +++ b/lib/protocol/http/header/te.rb @@ -70,8 +70,7 @@ def initialize(value = nil) super(value.map(&:downcase)) elsif value.is_a?(String) # Compatibility with the old constructor, prefer to use `parse` instead: - super() - self << value + super(value) elsif value raise ArgumentError, "Invalid value: #{value.inspect}" end diff --git a/lib/protocol/http/header/transfer_encoding.rb b/lib/protocol/http/header/transfer_encoding.rb index bade69d8..8d10001a 100644 --- a/lib/protocol/http/header/transfer_encoding.rb +++ b/lib/protocol/http/header/transfer_encoding.rb @@ -35,8 +35,7 @@ def initialize(value = nil) super(value.map(&:downcase)) elsif value.is_a?(String) # Compatibility with the old constructor, prefer to use `parse` instead: - super() - self << value + super(value) elsif value raise ArgumentError, "Invalid value: #{value.inspect}" end diff --git a/lib/protocol/http/header/vary.rb b/lib/protocol/http/header/vary.rb index 7f0a74fc..bf19f691 100644 --- a/lib/protocol/http/header/vary.rb +++ b/lib/protocol/http/header/vary.rb @@ -20,8 +20,7 @@ def initialize(value = nil) super(value.map(&:downcase)) elsif value.is_a?(String) # Compatibility with the old constructor, prefer to use `parse` instead: - super() - self << value + super(value) elsif value raise ArgumentError, "Invalid value: #{value.inspect}" end From 283bce9eabab624295220d48591f910702bf4d3b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 13 Dec 2025 11:26:38 +1300 Subject: [PATCH 5/8] More clean up. --- benchmark/array.rb | 26 +++++++++++++ gems.rb | 1 + lib/protocol/http/header/accept.rb | 38 +++---------------- lib/protocol/http/header/authorization.rb | 8 ++-- lib/protocol/http/header/cache_control.rb | 32 +++++++++++++--- lib/protocol/http/header/connection.rb | 35 +++++++++++++---- lib/protocol/http/header/date.rb | 8 ++-- lib/protocol/http/header/etag.rb | 8 ++-- lib/protocol/http/header/multiple.rb | 12 +++--- lib/protocol/http/header/priority.rb | 32 +++++++++++++--- lib/protocol/http/header/split.rb | 16 ++++---- lib/protocol/http/header/te.rb | 35 +++++++++++++---- lib/protocol/http/header/transfer_encoding.rb | 35 +++++++++++++---- lib/protocol/http/header/vary.rb | 35 +++++++++++++---- test/protocol/http/header/cache_control.rb | 28 ++++++++++---- test/protocol/http/header/connection.rb | 25 ++++++++---- test/protocol/http/header/priority.rb | 31 ++++++++++----- test/protocol/http/header/te.rb | 25 ++++++++---- .../protocol/http/header/transfer_encoding.rb | 25 ++++++++---- test/protocol/http/header/vary.rb | 21 ++++++++-- 20 files changed, 335 insertions(+), 141 deletions(-) create mode 100644 benchmark/array.rb diff --git a/benchmark/array.rb b/benchmark/array.rb new file mode 100644 index 00000000..274dca09 --- /dev/null +++ b/benchmark/array.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "sus/fixtures/benchmark" + +describe "Array initialization" do + include Sus::Fixtures::Benchmark + + let(:source_array) {["value1", "value2", "value3", "value4", "value5"]} + + measure "Array.new(array)" do |repeats| + repeats.times do + Array.new(source_array) + end + end + + measure "Array.new.concat(array)" do |repeats| + repeats.times do + array = Array.new + array.concat(source_array) + end + end +end + diff --git a/gems.rb b/gems.rb index 99a9438b..305400f2 100644 --- a/gems.rb +++ b/gems.rb @@ -29,6 +29,7 @@ gem "rubocop-socketry" gem "sus-fixtures-async" + gem "sus-fixtures-benchmark" gem "bake-test" gem "bake-test-external" diff --git a/lib/protocol/http/header/accept.rb b/lib/protocol/http/header/accept.rb index 96a13878..ec5cdf44 100644 --- a/lib/protocol/http/header/accept.rb +++ b/lib/protocol/http/header/accept.rb @@ -68,52 +68,26 @@ def quality_factor end end - # Parses a raw header value from the wire. + # Parses a raw header value. # - # @parameter value [String] the raw header value containing comma-separated media types. + # @parameter value [String] a raw header value containing comma-separated media types. # @returns [Accept] a new instance containing the parsed media types. def self.parse(value) self.new(value.scan(SEPARATOR).map(&:strip)) end - # Coerces a value into a parsed header object. - # - # @parameter value [String | Array] the value to coerce. - # @returns [Accept] 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 an Accept header with already-parsed values. - # - # @parameter value [Array | Nil] an array of parsed media type strings, or `nil` for an empty header. - def initialize(value = nil) - if value.is_a?(Array) - super(value) - elsif value.is_a?(String) - super(value) - elsif value - raise ArgumentError, "Invalid value: #{value.inspect}" - end - end - - # Adds one or more comma-separated values to the header from a raw wire-format string. + # Adds one or more comma-separated values to the header. # # The input string is split into distinct entries and appended to the array. # - # @parameter value [String] a raw wire-format value containing one or more media types separated by commas. + # @parameter value [String] a raw header value containing one or more media types separated by commas. def << value self.concat(value.scan(SEPARATOR).map(&:strip)) end - # Converts the parsed header value into a raw wire-format string. + # Converts the parsed header value into a raw header value. # - # @returns [String] a raw wire-format value (comma-separated string) suitable for transmission. + # @returns [String] a raw header value (comma-separated string). def to_s join(",") end diff --git a/lib/protocol/http/header/authorization.rb b/lib/protocol/http/header/authorization.rb index f3fcc1de..3d87dbfe 100644 --- a/lib/protocol/http/header/authorization.rb +++ b/lib/protocol/http/header/authorization.rb @@ -15,9 +15,9 @@ module Header # # TODO Support other authorization mechanisms, e.g. bearer token. class Authorization < String - # Parses a raw header value from the wire. + # Parses a raw header value. # - # @parameter value [String] the raw header value. + # @parameter value [String] a raw header value. # @returns [Authorization] a new instance. def self.parse(value) self.new(value) @@ -47,8 +47,8 @@ def self.basic(username, password) strict_base64_encoded = ["#{username}:#{password}"].pack("m0") self.new( - "Basic #{strict_base64_encoded}" - ) + "Basic #{strict_base64_encoded}" + ) end # Whether this header is acceptable in HTTP trailers. diff --git a/lib/protocol/http/header/cache_control.rb b/lib/protocol/http/header/cache_control.rb index cd78ef8a..a82589f7 100644 --- a/lib/protocol/http/header/cache_control.rb +++ b/lib/protocol/http/header/cache_control.rb @@ -44,14 +44,34 @@ class CacheControl < Split # The `proxy-revalidate` directive is similar to `must-revalidate` but applies only to shared caches. PROXY_REVALIDATE = "proxy-revalidate" - # Initializes the cache control header with already-parsed and normalized values. + # Parses a raw header value. # - # @parameter value [Array | Nil] an array of normalized (lowercase) directives, or `nil` for an empty header. + # @parameter value [String] a raw header value containing comma-separated directives. + # @returns [CacheControl] a new instance containing the parsed and normalized directives. + def self.parse(value) + self.new(value.downcase.split(COMMA)) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String | Array] the value to coerce. + # @returns [CacheControl] a parsed header object with normalized values. + def self.coerce(value) + case value + when Array + self.new(value.map(&:downcase)) + else + self.parse(value.to_s) + end + end + + # Initializes the cache control header with the given values. + # + # @parameter value [Array | String | Nil] an array of directives, a raw header value, or `nil` for an empty header. def initialize(value = nil) if value.is_a?(Array) - super(value.map(&:downcase)) + super(value) elsif value.is_a?(String) - # Compatibility with the old constructor, prefer to use `parse` instead: super() self << value elsif value @@ -59,9 +79,9 @@ def initialize(value = nil) end end - # Adds a directive to the Cache-Control header from a raw wire-format string. The value will be normalized to lowercase before being added. + # Adds a directive to the Cache-Control header. The value will be normalized to lowercase before being added. # - # @parameter value [String] a raw wire-format directive to add. + # @parameter value [String] a raw header value containing directives to add. def << value super(value.downcase) end diff --git a/lib/protocol/http/header/connection.rb b/lib/protocol/http/header/connection.rb index 3bd9099b..f4b03c30 100644 --- a/lib/protocol/http/header/connection.rb +++ b/lib/protocol/http/header/connection.rb @@ -22,23 +22,44 @@ class Connection < Split # The `upgrade` directive indicates that the connection should be upgraded to a different protocol, as specified in the `Upgrade` header. UPGRADE = "upgrade" - # Initializes the connection header with already-parsed and normalized values. + # Parses a raw header value. # - # @parameter value [Array | Nil] an array of normalized (lowercase) directives, or `nil` for an empty header. + # @parameter value [String] a raw header value containing comma-separated directives. + # @returns [Connection] a new instance with normalized (lowercase) directives. + def self.parse(value) + self.new(value.downcase.split(COMMA)) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String | Array] the value to coerce. + # @returns [Connection] a parsed header object with normalized values. + def self.coerce(value) + case value + when Array + self.new(value.map(&:downcase)) + else + self.parse(value.to_s) + end + end + + # Initializes the connection header with the given values. + # + # @parameter value [Array | String | Nil] an array of directives, a raw header value, or `nil` for an empty header. def initialize(value = nil) if value.is_a?(Array) - super(value.map(&:downcase)) - elsif value.is_a?(String) - # Compatibility with the old constructor, prefer to use `parse` instead: super(value) + elsif value.is_a?(String) + super() + self << value elsif value raise ArgumentError, "Invalid value: #{value.inspect}" end end - # Adds a directive to the `connection` header from a raw wire-format string. The value will be normalized to lowercase before being added. + # Adds a directive to the `connection` header. The value will be normalized to lowercase before being added. # - # @parameter value [String] a raw wire-format directive to add. + # @parameter value [String] a raw header value containing directives to add. def << value super(value.downcase) end diff --git a/lib/protocol/http/header/date.rb b/lib/protocol/http/header/date.rb index 69a90934..517773e8 100644 --- a/lib/protocol/http/header/date.rb +++ b/lib/protocol/http/header/date.rb @@ -12,9 +12,9 @@ module Header # # This header is typically included in HTTP responses and follows the format defined in RFC 9110. class Date < String - # Parses a raw header value from the wire. + # Parses a raw header value. # - # @parameter value [String] the raw header value. + # @parameter value [String] a raw header value. # @returns [Date] a new instance. def self.parse(value) self.new(value) @@ -28,9 +28,9 @@ def self.coerce(value) self.new(value.to_s) end - # Replaces the current value of the `date` header with a raw wire-format string. + # Replaces the current value of the `date` header. # - # @parameter value [String] a raw wire-format value for the `date` header. + # @parameter value [String] a raw header value for the `date` header. def << value replace(value) end diff --git a/lib/protocol/http/header/etag.rb b/lib/protocol/http/header/etag.rb index aad66267..9fe2d482 100644 --- a/lib/protocol/http/header/etag.rb +++ b/lib/protocol/http/header/etag.rb @@ -10,9 +10,9 @@ module Header # # The `etag` header provides a unique identifier for a specific version of a resource, typically used for cache validation or conditional requests. It can be either a strong or weak validator as defined in RFC 9110. class ETag < String - # Parses a raw header value from the wire. + # Parses a raw header value. # - # @parameter value [String] the raw header value. + # @parameter value [String] a raw header value. # @returns [ETag] a new instance. def self.parse(value) self.new(value) @@ -26,9 +26,9 @@ def self.coerce(value) self.new(value.to_s) end - # Replaces the current value of the `etag` header with a raw wire-format string. + # Replaces the current value of the `etag` header. # - # @parameter value [String] a raw wire-format value for the `etag` header. + # @parameter value [String] a raw header value for the `etag` header. def << value replace(value) end diff --git a/lib/protocol/http/header/multiple.rb b/lib/protocol/http/header/multiple.rb index 64fe4a08..4bcd9643 100644 --- a/lib/protocol/http/header/multiple.rb +++ b/lib/protocol/http/header/multiple.rb @@ -10,11 +10,11 @@ module Header # # This isn't a specific header but is used as a base for headers that store multiple values, such as cookies. The values are split and stored as an array internally, and serialized back to a newline-separated string when needed. class Multiple < Array - # Parses a raw header value from the wire. + # Parses a raw header value. # - # Multiple headers receive each value as a separate header entry on the wire, so this method takes a single string value and creates a new instance containing it. + # Multiple headers receive each value as a separate header entry, so this method takes a single string value and creates a new instance containing it. # - # @parameter value [String] a single raw header value from the wire. + # @parameter value [String] a single raw header value. # @returns [Multiple] a new instance containing the parsed value. def self.parse(value) self.new([value]) @@ -46,11 +46,11 @@ def initialize(value = nil) end end - # Converts the parsed header value into a raw wire-format string. + # Converts the parsed header value into a raw header value. # - # Multiple headers are transmitted as separate header entries on the wire, so this serializes to a newline-separated string for storage. + # Multiple headers are transmitted as separate header entries, so this serializes to a newline-separated string for storage. # - # @returns [String] a raw wire-format value (newline-separated string). + # @returns [String] a raw header value (newline-separated string). def to_s join("\n") end diff --git a/lib/protocol/http/header/priority.rb b/lib/protocol/http/header/priority.rb index c8cbc416..85531fb0 100644 --- a/lib/protocol/http/header/priority.rb +++ b/lib/protocol/http/header/priority.rb @@ -12,14 +12,34 @@ module Header # # The `priority` header allows clients to express their preference for how resources should be prioritized by the server. It supports directives like `u=` to specify the urgency level of a request, and `i` to indicate whether a response can be delivered incrementally. The urgency levels range from 0 (highest priority) to 7 (lowest priority), while the `i` directive is a boolean flag. class Priority < Split - # Initializes the priority header with already-parsed and normalized values. + # Parses a raw header value. # - # @parameter value [Array | Nil] an array of normalized (lowercase) directives, or `nil` for an empty header. + # @parameter value [String] a raw header value containing comma-separated directives. + # @returns [Priority] a new instance with normalized (lowercase) directives. + def self.parse(value) + self.new(value.downcase.split(COMMA)) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String | Array] the value to coerce. + # @returns [Priority] a parsed header object with normalized values. + def self.coerce(value) + case value + when Array + self.new(value.map(&:downcase)) + else + self.parse(value.to_s) + end + end + + # Initializes the priority header with the given values. + # + # @parameter value [Array | String | Nil] an array of directives, a raw header value, or `nil` for an empty header. def initialize(value = nil) if value.is_a?(Array) - super(value.map(&:downcase)) + super(value) elsif value.is_a?(String) - # Compatibility with the old constructor, prefer to use `parse` instead: super() self << value elsif value @@ -27,9 +47,9 @@ def initialize(value = nil) end end - # Add a value to the priority header from a raw wire-format string. + # Add a value to the priority header. # - # @parameter value [String] a raw wire-format directive to add to the header. + # @parameter value [String] a raw header value containing directives to add to the header. def << value super(value.downcase) end diff --git a/lib/protocol/http/header/split.rb b/lib/protocol/http/header/split.rb index df57131a..6bdb445b 100644 --- a/lib/protocol/http/header/split.rb +++ b/lib/protocol/http/header/split.rb @@ -13,11 +13,11 @@ class Split < Array # Regular expression used to split values on commas, with optional surrounding whitespace. COMMA = /\s*,\s*/ - # Parses a raw header value from the wire. + # Parses a raw header value. # - # Split headers receive comma-separated values in a single header entry on the wire. This method splits the raw value into individual entries. + # Split headers receive comma-separated values in a single header entry. This method splits the raw value into individual entries. # - # @parameter value [String] the raw header value containing multiple entries separated by commas. + # @parameter value [String] a raw header value containing multiple entries separated by commas. # @returns [Split] a new instance containing the parsed values. def self.parse(value) self.new(value.split(COMMA)) @@ -32,7 +32,7 @@ def self.parse(value) def self.coerce(value) case value when Array - self.new(value) + self.new(value.map(&:to_s)) else self.parse(value.to_s) end @@ -53,18 +53,18 @@ def initialize(value = nil) end end - # Adds one or more comma-separated values to the header from a raw wire-format string. + # Adds one or more comma-separated values to the header. # # The input string is split into distinct entries and appended to the array. # - # @parameter value [String] a raw wire-format value containing one or more values separated by commas. + # @parameter value [String] a raw header value containing one or more values separated by commas. def << value self.concat(value.split(COMMA)) end - # Converts the parsed header value into a raw wire-format string. + # Converts the parsed header value into a raw header value. # - # @returns [String] a raw wire-format value (comma-separated string) suitable for transmission. + # @returns [String] a raw header value (comma-separated string). def to_s join(",") end diff --git a/lib/protocol/http/header/te.rb b/lib/protocol/http/header/te.rb index 5b8558a6..7cdf00e3 100644 --- a/lib/protocol/http/header/te.rb +++ b/lib/protocol/http/header/te.rb @@ -62,23 +62,44 @@ def to_s end end - # Initializes the TE header with already-parsed and normalized values. + # Parses a raw header value. # - # @parameter value [Array | Nil] an array of normalized (lowercase) encodings, or `nil` for an empty header. + # @parameter value [String] a raw header value containing comma-separated encodings. + # @returns [TE] a new instance with normalized (lowercase) encodings. + def self.parse(value) + self.new(value.downcase.split(COMMA)) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String | Array] the value to coerce. + # @returns [TE] a parsed header object with normalized values. + def self.coerce(value) + case value + when Array + self.new(value.map(&:downcase)) + else + self.parse(value.to_s) + end + end + + # Initializes the TE header with the given values. + # + # @parameter value [Array | String | Nil] an array of encodings, a raw header value, or `nil` for an empty header. def initialize(value = nil) if value.is_a?(Array) - super(value.map(&:downcase)) - elsif value.is_a?(String) - # Compatibility with the old constructor, prefer to use `parse` instead: super(value) + elsif value.is_a?(String) + super() + self << value elsif value raise ArgumentError, "Invalid value: #{value.inspect}" end end - # Adds one or more comma-separated values to the TE header from a raw wire-format string. The values are converted to lowercase for normalization. + # Adds one or more comma-separated values to the TE header. The values are converted to lowercase for normalization. # - # @parameter value [String] a raw wire-format value containing one or more values separated by commas. + # @parameter value [String] a raw header value containing one or more values separated by commas. def << value super(value.downcase) end diff --git a/lib/protocol/http/header/transfer_encoding.rb b/lib/protocol/http/header/transfer_encoding.rb index 8d10001a..3fb8cacf 100644 --- a/lib/protocol/http/header/transfer_encoding.rb +++ b/lib/protocol/http/header/transfer_encoding.rb @@ -27,23 +27,44 @@ class TransferEncoding < Split # The `identity` transfer encoding indicates no transformation has been applied. IDENTITY = "identity" - # Initializes the transfer encoding header with already-parsed and normalized values. + # Parses a raw header value. # - # @parameter value [Array | Nil] an array of normalized (lowercase) encodings, or `nil` for an empty header. + # @parameter value [String] a raw header value containing comma-separated encodings. + # @returns [TransferEncoding] a new instance with normalized (lowercase) encodings. + def self.parse(value) + self.new(value.downcase.split(COMMA)) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String | Array] the value to coerce. + # @returns [TransferEncoding] a parsed header object with normalized values. + def self.coerce(value) + case value + when Array + self.new(value.map(&:downcase)) + else + self.parse(value.to_s) + end + end + + # Initializes the transfer encoding header with the given values. + # + # @parameter value [Array | String | Nil] an array of encodings, a raw header value, or `nil` for an empty header. def initialize(value = nil) if value.is_a?(Array) - super(value.map(&:downcase)) - elsif value.is_a?(String) - # Compatibility with the old constructor, prefer to use `parse` instead: super(value) + elsif value.is_a?(String) + super() + self << value elsif value raise ArgumentError, "Invalid value: #{value.inspect}" end end - # Adds one or more comma-separated values to the transfer encoding header from a raw wire-format string. The values are converted to lowercase for normalization. + # Adds one or more comma-separated values to the transfer encoding header. The values are converted to lowercase for normalization. # - # @parameter value [String] a raw wire-format value containing one or more values separated by commas. + # @parameter value [String] a raw header value containing one or more values separated by commas. def << value super(value.downcase) end diff --git a/lib/protocol/http/header/vary.rb b/lib/protocol/http/header/vary.rb index bf19f691..b3ce48f9 100644 --- a/lib/protocol/http/header/vary.rb +++ b/lib/protocol/http/header/vary.rb @@ -12,23 +12,44 @@ module Header # # The `vary` header is used in HTTP responses to indicate which request headers affect the selected response. It allows caches to differentiate stored responses based on specific request headers. class Vary < Split - # Initializes a `Vary` header with already-parsed and normalized values. + # Parses a raw header value. # - # @parameter value [Array | Nil] an array of normalized (lowercase) header names, or `nil` for an empty header. + # @parameter value [String] a raw header value containing comma-separated header names. + # @returns [Vary] a new instance with normalized (lowercase) header names. + def self.parse(value) + self.new(value.downcase.split(COMMA)) + end + + # Coerces a value into a parsed header object. + # + # @parameter value [String | Array] the value to coerce. + # @returns [Vary] a parsed header object with normalized values. + def self.coerce(value) + case value + when Array + self.new(value.map(&:downcase)) + else + self.parse(value.to_s) + end + end + + # Initializes a `Vary` header with the given values. + # + # @parameter value [Array | String | Nil] an array of header names, a raw header value, or `nil` for an empty header. def initialize(value = nil) if value.is_a?(Array) - super(value.map(&:downcase)) - elsif value.is_a?(String) - # Compatibility with the old constructor, prefer to use `parse` instead: super(value) + elsif value.is_a?(String) + super() + self << value elsif value raise ArgumentError, "Invalid value: #{value.inspect}" end end - # Adds one or more comma-separated values to the `vary` header from a raw wire-format string. The values are converted to lowercase for normalization. + # Adds one or more comma-separated values to the `vary` header. The values are converted to lowercase for normalization. # - # @parameter value [String] a raw wire-format value containing one or more values separated by commas. + # @parameter value [String] a raw header value containing one or more values separated by commas. def << value super(value.downcase) end diff --git a/test/protocol/http/header/cache_control.rb b/test/protocol/http/header/cache_control.rb index bb6d0f77..77371c0f 100644 --- a/test/protocol/http/header/cache_control.rb +++ b/test/protocol/http/header/cache_control.rb @@ -89,20 +89,32 @@ end end - with "normalization" do - it "normalizes to lowercase when initialized with string" do - header = subject.new("PUBLIC, MAX-AGE=60") + with ".coerce" do + it "normalizes array values to lowercase" do + header = subject.coerce(["PUBLIC", "NO-CACHE"]) expect(header).to be(:include?, "public") - expect(header).to be(:include?, "max-age=60") + expect(header).to be(:include?, "no-cache") expect(header).not.to be(:include?, "PUBLIC") end - it "normalizes to lowercase when initialized with array" do + it "normalizes string values to lowercase" do + header = subject.coerce("PUBLIC, MAX-AGE=60") + expect(header).to be(:include?, "public") + expect(header).to be(:include?, "max-age=60") + end + end + + with ".new" do + it "preserves case when given array" do header = subject.new(["PUBLIC", "NO-CACHE"]) + expect(header).to be(:include?, "PUBLIC") + expect(header).to be(:include?, "NO-CACHE") + end + + it "normalizes when given string (backward compatibility)" do + header = subject.new("PUBLIC, MAX-AGE=60") expect(header).to be(:include?, "public") - expect(header).to be(:include?, "no-cache") - expect(header).not.to be(:include?, "PUBLIC") - expect(header).not.to be(:include?, "NO-CACHE") + expect(header).to be(:include?, "max-age=60") end it "raises ArgumentError for invalid value types" do diff --git a/test/protocol/http/header/connection.rb b/test/protocol/http/header/connection.rb index 19a2ad7f..88c139f6 100644 --- a/test/protocol/http/header/connection.rb +++ b/test/protocol/http/header/connection.rb @@ -57,21 +57,32 @@ end end - with "normalization" do - it "normalizes to lowercase when initialized with string" do - header = subject.new("CLOSE, UPGRADE") + with ".coerce" do + it "normalizes array values to lowercase" do + header = subject.coerce(["CLOSE", "UPGRADE"]) expect(header).to be(:include?, "close") expect(header).to be(:include?, "upgrade") expect(header).not.to be(:include?, "CLOSE") - expect(header).not.to be(:include?, "UPGRADE") end - it "normalizes to lowercase when initialized with array" do + it "normalizes string values to lowercase" do + header = subject.coerce("CLOSE, UPGRADE") + expect(header).to be(:include?, "close") + expect(header).to be(:include?, "upgrade") + end + end + + with ".new" do + it "preserves case when given array" do header = subject.new(["CLOSE", "UPGRADE"]) + expect(header).to be(:include?, "CLOSE") + expect(header).to be(:include?, "UPGRADE") + end + + it "normalizes when given string (backward compatibility)" do + header = subject.new("CLOSE, UPGRADE") expect(header).to be(:include?, "close") expect(header).to be(:include?, "upgrade") - expect(header).not.to be(:include?, "CLOSE") - expect(header).not.to be(:include?, "UPGRADE") end it "raises ArgumentError for invalid value types" do diff --git a/test/protocol/http/header/priority.rb b/test/protocol/http/header/priority.rb index c0b4bfc5..3ca415ba 100644 --- a/test/protocol/http/header/priority.rb +++ b/test/protocol/http/header/priority.rb @@ -80,21 +80,32 @@ end end - with "normalization" do - it "normalizes to lowercase when initialized with string" do - header = subject.new("U=5, I") - expect(header).to be(:include?, "u=5") + with ".coerce" do + it "normalizes array values to lowercase" do + header = subject.coerce(["U=3", "I"]) + expect(header).to be(:include?, "u=3") expect(header).to be(:include?, "i") - expect(header).not.to be(:include?, "U=5") - expect(header).not.to be(:include?, "I") + expect(header).not.to be(:include?, "U=3") end - it "normalizes to lowercase when initialized with array" do + it "normalizes string values to lowercase" do + header = subject.coerce("U=5, I") + expect(header).to be(:include?, "u=5") + expect(header).to be(:include?, "i") + end + end + + with ".new" do + it "preserves case when given array" do header = subject.new(["U=3", "I"]) - expect(header).to be(:include?, "u=3") + expect(header).to be(:include?, "U=3") + expect(header).to be(:include?, "I") + end + + it "normalizes when given string (backward compatibility)" do + header = subject.new("U=5, I") + expect(header).to be(:include?, "u=5") expect(header).to be(:include?, "i") - expect(header).not.to be(:include?, "U=3") - expect(header).not.to be(:include?, "I") end it "raises ArgumentError for invalid value types" do diff --git a/test/protocol/http/header/te.rb b/test/protocol/http/header/te.rb index c31fb2af..7ba434b3 100644 --- a/test/protocol/http/header/te.rb +++ b/test/protocol/http/header/te.rb @@ -104,21 +104,32 @@ end end - with "normalization" do - it "normalizes to lowercase when initialized with string" do - header = subject.new("GZIP, CHUNKED") + with ".coerce" do + it "normalizes array values to lowercase" do + header = subject.coerce(["GZIP", "CHUNKED"]) expect(header).to be(:include?, "gzip") expect(header).to be(:include?, "chunked") expect(header).not.to be(:include?, "GZIP") - expect(header).not.to be(:include?, "CHUNKED") end - it "normalizes to lowercase when initialized with array" do + it "normalizes string values to lowercase" do + header = subject.coerce("GZIP, CHUNKED") + expect(header).to be(:include?, "gzip") + expect(header).to be(:include?, "chunked") + end + end + + with ".new" do + it "preserves case when given array" do header = subject.new(["GZIP", "CHUNKED"]) + expect(header).to be(:include?, "GZIP") + expect(header).to be(:include?, "CHUNKED") + end + + it "normalizes when given string (backward compatibility)" do + header = subject.new("GZIP, CHUNKED") expect(header).to be(:include?, "gzip") expect(header).to be(:include?, "chunked") - expect(header).not.to be(:include?, "GZIP") - expect(header).not.to be(:include?, "CHUNKED") end it "raises ArgumentError for invalid value types" do diff --git a/test/protocol/http/header/transfer_encoding.rb b/test/protocol/http/header/transfer_encoding.rb index 56074a7d..0372d532 100644 --- a/test/protocol/http/header/transfer_encoding.rb +++ b/test/protocol/http/header/transfer_encoding.rb @@ -75,21 +75,32 @@ end end - with "normalization" do - it "normalizes to lowercase when initialized with string" do - header = subject.new("GZIP, CHUNKED") + with ".coerce" do + it "normalizes array values to lowercase" do + header = subject.coerce(["GZIP", "CHUNKED"]) expect(header).to be(:include?, "gzip") expect(header).to be(:include?, "chunked") expect(header).not.to be(:include?, "GZIP") - expect(header).not.to be(:include?, "CHUNKED") end - it "normalizes to lowercase when initialized with array" do + it "normalizes string values to lowercase" do + header = subject.coerce("GZIP, CHUNKED") + expect(header).to be(:include?, "gzip") + expect(header).to be(:include?, "chunked") + end + end + + with ".new" do + it "preserves case when given array" do header = subject.new(["GZIP", "CHUNKED"]) + expect(header).to be(:include?, "GZIP") + expect(header).to be(:include?, "CHUNKED") + end + + it "normalizes when given string (backward compatibility)" do + header = subject.new("GZIP, CHUNKED") expect(header).to be(:include?, "gzip") expect(header).to be(:include?, "chunked") - expect(header).not.to be(:include?, "GZIP") - expect(header).not.to be(:include?, "CHUNKED") end it "raises ArgumentError for invalid value types" do diff --git a/test/protocol/http/header/vary.rb b/test/protocol/http/header/vary.rb index f0933933..174e35c7 100644 --- a/test/protocol/http/header/vary.rb +++ b/test/protocol/http/header/vary.rb @@ -35,13 +35,26 @@ end end - with "normalization" do - it "normalizes to lowercase when initialized with array" do - header = subject.new(["Accept-Language", "User-Agent"]) + with ".coerce" do + it "normalizes array values to lowercase" do + header = subject.coerce(["Accept-Language", "User-Agent"]) expect(header).to be(:include?, "accept-language") expect(header).to be(:include?, "user-agent") expect(header).not.to be(:include?, "Accept-Language") - expect(header).not.to be(:include?, "User-Agent") + end + + it "normalizes string values to lowercase" do + header = subject.coerce("Accept-Language, User-Agent") + expect(header).to be(:include?, "accept-language") + expect(header).to be(:include?, "user-agent") + end + end + + with ".new" do + it "preserves case when given array" do + header = subject.new(["Accept-Language", "User-Agent"]) + expect(header).to be(:include?, "Accept-Language") + expect(header).to be(:include?, "User-Agent") end it "can initialize with string for backward compatibility" do From 20f2acaddbfdbb072f76cc22018ec568d8c10984 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 13 Dec 2025 11:31:30 +1300 Subject: [PATCH 6/8] Improve documentation consistency. --- lib/protocol/http/header/multiple.rb | 2 +- lib/protocol/http/header/split.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/protocol/http/header/multiple.rb b/lib/protocol/http/header/multiple.rb index 4bcd9643..d5a850c2 100644 --- a/lib/protocol/http/header/multiple.rb +++ b/lib/protocol/http/header/multiple.rb @@ -35,7 +35,7 @@ def self.coerce(value) end end - # Initializes the multiple header with already-parsed values. + # Initializes the multiple header with the given values. # # @parameter value [Array | Nil] an array of header values, or `nil` for an empty header. def initialize(value = nil) diff --git a/lib/protocol/http/header/split.rb b/lib/protocol/http/header/split.rb index 6bdb445b..9def591b 100644 --- a/lib/protocol/http/header/split.rb +++ b/lib/protocol/http/header/split.rb @@ -38,9 +38,9 @@ def self.coerce(value) end end - # Initializes a `Split` header with already-parsed values. + # Initializes a `Split` header with the given values. # - # @parameter value [Array | Nil] an array of parsed header values, or `nil` for an empty header. + # @parameter value [Array | String | Nil] an array of values, a raw header value, or `nil` for an empty header. def initialize(value = nil) if value.is_a?(Array) super(value) From 022b2d07c5f0173408d6a28b91d7e70d01650416 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 13 Dec 2025 11:34:50 +1300 Subject: [PATCH 7/8] Improve test consistency. --- test/protocol/http/header/accept.rb | 10 ++++++++-- test/protocol/http/header/trailer.rb | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/test/protocol/http/header/accept.rb b/test/protocol/http/header/accept.rb index 2a087a7c..20630a0c 100644 --- a/test/protocol/http/header/accept.rb +++ b/test/protocol/http/header/accept.rb @@ -98,8 +98,14 @@ end end - with "backward compatibility" do - it "can initialize with string" do + with ".new" do + it "preserves values when given array" do + header = subject.new(["text/html", "application/json"]) + expect(header).to be(:include?, "text/html") + expect(header).to be(:include?, "application/json") + end + + it "can initialize with string (backward compatibility)" do header = subject.new("text/plain, text/html") expect(header).to be(:include?, "text/plain") expect(header).to be(:include?, "text/html") diff --git a/test/protocol/http/header/trailer.rb b/test/protocol/http/header/trailer.rb index 753849fb..31229855 100644 --- a/test/protocol/http/header/trailer.rb +++ b/test/protocol/http/header/trailer.rb @@ -74,8 +74,14 @@ end end - with "backward compatibility" do - it "can initialize with string for backward compatibility" do + with ".new" do + it "preserves values when given array" do + header = subject.new(["etag", "content-md5"]) + expect(header).to be(:include?, "etag") + expect(header).to be(:include?, "content-md5") + end + + it "can initialize with string (backward compatibility)" do header = subject.new("etag, content-md5") expect(header).to be(:include?, "etag") expect(header).to be(:include?, "content-md5") From 6d1601283132c86bc83463b11f72858c0bf69348 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 13 Dec 2025 11:53:57 +1300 Subject: [PATCH 8/8] Improve release notes. --- releases.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/releases.md b/releases.md index e3c552d6..833ac12b 100644 --- a/releases.md +++ b/releases.md @@ -2,10 +2,11 @@ ## Unreleased - - Introduce `Header::*.parse(value)` which converts a string representation to a header instance. - - Introduce `Header::*.coerce(value)` which converts a rich representation (e.g. `Array`) to a header instance. - - `Header::*#initialize` still implements parse-like behaviour, but it's considered deprecated. - - Update `Headers#[]=` to use `parse(value)` for conversion. This provides better symmetry with `Headers#[]`. + - Introduce `Header::*.parse(value)` which parses a raw header value string into a header instance. + - Introduce `Header::*.coerce(value)` which coerces any value (`String`, `Array`, etc.) into a header instance with normalization. + - `Header::*#initialize` now accepts arrays without normalization for efficiency, or strings for backward compatibility. + - Update `Headers#[]=` to use `coerce(value)` for smart conversion of user input. + - Normalization (e.g., lowercasing) is applied by `parse`, `coerce`, and `<<` methods, but not by `new` when given arrays. ## v0.55.0