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/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/header/accept.rb b/lib/protocol/http/header/accept.rb index 773af895..ec5cdf44 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 @@ -68,27 +68,26 @@ def quality_factor end end - # Parse the `accept` header value into a list of content types. + # Parses a raw header value. # - # @parameter value [String] the value of the header. - def initialize(value = nil) - if value - super(value.scan(SEPARATOR).map(&:strip)) - end + # @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 # 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] the value or values to add, 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 - # Serializes the stored values into a comma-separated string. + # Converts the parsed header value into a raw header value. # - # @returns [String] the serialized representation of the header values. + # @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 2484bf20..3d87dbfe 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. + # + # @parameter value [String] a 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. @@ -31,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 a84edb8d..a82589f7 100644 --- a/lib/protocol/http/header/cache_control.rb +++ b/lib/protocol/http/header/cache_control.rb @@ -44,16 +44,44 @@ 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. + # Parses a raw header value. # - # @parameter value [String | Nil] the raw Cache-Control header value. + # @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) - super(value&.downcase) + if value.is_a?(Array) + 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 Cache-Control header. The value will be normalized to lowercase before being added. # - # @parameter value [String] the directive to add. + # @parameter value [String] a raw header value containing directives to add. def << value super(value.downcase) end @@ -132,3 +160,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..f4b03c30 100644 --- a/lib/protocol/http/header/connection.rb +++ b/lib/protocol/http/header/connection.rb @@ -22,16 +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 the given value. The value is expected to be a comma-separated string of directives. + # Parses a raw header value. # - # @parameter value [String | Nil] the raw `connection` header value. + # @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) - super(value&.downcase) + if value.is_a?(Array) + 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. The value will be normalized to lowercase before being added. # - # @parameter value [String] the directive to add. + # @parameter value [String] a raw header value containing directives to add. def << value super(value.downcase) end @@ -61,3 +89,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..517773e8 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. # - # @parameter value [String] the new value for the `date` header. + # @parameter value [String] a 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. + # + # @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 c4f86f96..9fe2d482 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. # - # @parameter value [String] the new value for the `etag` header. + # @parameter value [String] a 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. + # + # @parameter value [String] a raw header 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..d5a850c2 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. # - # @parameter value [String] the raw header value. - def initialize(value) + # 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. + # @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.map(&:to_s)) + else + self.parse(value.to_s) + end + end + + # 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) 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 header value. + # + # Multiple headers are transmitted as separate header entries, so this serializes to a newline-separated string for storage. # - # @returns [String] the serialized representation of the header values. + # @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 b4522a50..85531fb0 100644 --- a/lib/protocol/http/header/priority.rb +++ b/lib/protocol/http/header/priority.rb @@ -12,16 +12,44 @@ 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. + # Parses a raw header value. # - # @parameter value [String | Nil] the value of the priority header, if any. The value should be a comma-separated string of directives. + # @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) - super(value&.downcase) + if value.is_a?(Array) + super(value) + elsif value.is_a?(String) + super() + self << value + elsif value + raise ArgumentError, "Invalid value: #{value.inspect}" + end end # Add a value to the priority header. # - # @parameter value [String] the 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 @@ -55,3 +83,4 @@ def incremental? end end end + diff --git a/lib/protocol/http/header/split.rb b/lib/protocol/http/header/split.rb index 2301b925..9def591b 100644 --- a/lib/protocol/http/header/split.rb +++ b/lib/protocol/http/header/split.rb @@ -13,14 +13,43 @@ 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. # - # @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. This method splits the raw value into individual entries. + # + # @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)) + 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.map(&:to_s)) else + self.parse(value.to_s) + end + end + + # Initializes a `Split` header with the given values. + # + # @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) + 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 @@ -28,14 +57,14 @@ def initialize(value = nil) # # 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 header 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 header value. # - # @returns [String] the serialized representation of the header values. + # @returns [String] a raw header value (comma-separated string). 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..7cdf00e3 100644 --- a/lib/protocol/http/header/te.rb +++ b/lib/protocol/http/header/te.rb @@ -62,16 +62,44 @@ 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. + # Parses a raw header value. # - # @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas. + # @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) - super(value&.downcase) + if value.is_a?(Array) + 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. 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 header value containing one or more values separated by commas. def << value super(value.downcase) end @@ -129,3 +157,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..3fb8cacf 100644 --- a/lib/protocol/http/header/transfer_encoding.rb +++ b/lib/protocol/http/header/transfer_encoding.rb @@ -27,16 +27,44 @@ 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. + # Parses a raw header value. # - # @parameter value [String | Nil] the raw header value containing transfer encodings separated by commas. + # @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) - super(value&.downcase) + if value.is_a?(Array) + 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. 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 header value containing one or more values separated by commas. def << value super(value.downcase) end @@ -76,3 +104,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..b3ce48f9 100644 --- a/lib/protocol/http/header/vary.rb +++ b/lib/protocol/http/header/vary.rb @@ -12,16 +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 the given value. The value is split into distinct entries and converted to lowercase for normalization. + # Parses a raw header value. # - # @parameter value [String] the raw header value containing request header names separated by commas. - def initialize(value) - super(value.downcase) + # @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) + 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. 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 header value containing one or more values separated by commas. def << value super(value.downcase) end @@ -29,3 +57,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..833ac12b 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,13 @@ # Releases +## Unreleased + + - 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 - **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..20630a0c 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 @@ -83,4 +83,36 @@ ) 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 ".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") + 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/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/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 5246b45a..77371c0f 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,37 @@ ) end end + + 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?, "no-cache") + expect(header).not.to be(:include?, "PUBLIC") + end + + 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?, "max-age=60") + 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 5e8a0f9a..88c139f6 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,37 @@ expect(header.to_s).to be == "close,upgrade" end end + + 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") + end + + 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") + 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/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..6ed8b769 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 @@ -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/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..9a342054 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 @@ -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/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..0a787a12 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 @@ -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 0346cc89..3ca415ba 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,37 @@ ) end end + + 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=3") + end + + 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?, "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") + 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/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..7ba434b3 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,39 @@ expect(subject).not.to be(:trailer?) end end + + 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") + end + + 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") + end + + it "raises ArgumentError for invalid value types" do + expect{subject.new(123)}.to raise_exception(ArgumentError) + 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..31229855 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" @@ -73,4 +73,22 @@ expect(subject).not.to be(:trailer?) end end + + 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") + 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 2cfd7e30..0372d532 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,37 @@ expect(subject).not.to be(:trailer?) end end + + 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") + end + + 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") + 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 fce26c49..174e35c7 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,37 @@ expect(header).not.to be(:include?, "Accept-Language") end end + + 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") + 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 + 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 bfaca514..517a49fb 100644 --- a/test/protocol/http/headers.rb +++ b/test/protocol/http/headers.rb @@ -195,17 +195,23 @@ 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 + 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?)