# frozen_string_literal: true
# Released under the MIT License.
# Copyright, 2018-2025, by Samuel Williams.
require_relative "header/split"
require_relative "header/multiple"
require_relative "header/cookie"
require_relative "header/connection"
require_relative "header/cache_control"
require_relative "header/etag"
require_relative "header/etags"
require_relative "header/vary"
require_relative "header/authorization"
require_relative "header/date"
require_relative "header/priority"
require_relative "header/accept"
require_relative "header/accept_charset"
require_relative "header/accept_encoding"
require_relative "header/accept_language"
module Protocol
module HTTP
# @namespace
module Header
end
# Headers are an array of key-value pairs. Some header keys represent multiple values.
class Headers
Split = Header::Split
Multiple = Header::Multiple
TRAILER = "trailer"
# Construct an instance from a headers Array or Hash. No-op if already an instance of `Headers`. If the underlying array is frozen, it will be duped.
#
# @return [Headers] an instance of headers.
def self.[] headers
if headers.nil?
return self.new
end
if headers.is_a?(self)
if headers.frozen?
return headers.dup
else
return headers
end
end
fields = headers.to_a
if fields.frozen?
fields = fields.dup
end
return self.new(fields)
end
# Initialize the headers with the specified fields.
#
# @parameter fields [Array] An array of `[key, value]` pairs.
# @parameter indexed [Hash] A hash table of normalized headers, if available.
def initialize(fields = [], indexed = nil)
@fields = fields
@indexed = indexed
# Marks where trailer start in the @fields array.
@tail = nil
end
# Initialize a copy of the headers.
#
# @parameter other [Headers] The headers to copy.
def initialize_dup(other)
super
@fields = @fields.dup
@indexed = @indexed.dup
end
# Clear all headers.
def clear
@fields.clear
@indexed = nil
@tail = nil
end
# Flatten trailer into the headers, in-place.
def flatten!
if @tail
self.delete(TRAILER)
@tail = nil
end
return self
end
# Flatten trailer into the headers, returning a new instance of {Headers}.
def flatten
self.dup.flatten!
end
# @attribute [Array] An array of `[key, value]` pairs.
attr :fields
# @returns [Boolean] Whether there are any trailers.
def trailer?
@tail != nil
end
# Record the current headers, and prepare to add trailers.
#
# This method is typically used after headers are sent to capture any additional headers which should then be sent as trailers.
#
# A sender that intends to generate one or more trailer fields in a message should generate a trailer header field in the header section of that message to indicate which fields might be present in the trailers.
#
# @parameter names [Array] The trailer header names which will be added later.
# @yields {|name, value| ...} the trailing headers if a block is given.
# @returns An enumerator which is suitable for iterating over trailers.
def trailer!(&block)
@tail ||= @fields.size
return trailer(&block)
end
# Enumerate all headers in the trailer, if there are any.
def trailer(&block)
return to_enum(:trailer) unless block_given?
if @tail
@fields.drop(@tail).each(&block)
end
end
# Freeze the headers, and ensure the indexed hash is generated.
def freeze
return if frozen?
# Ensure @indexed is generated:
self.to_h
@fields.freeze
@indexed.freeze
super
end
# @returns [Boolean] Whether the headers are empty.
def empty?
@fields.empty?
end
# Enumerate all header keys and values.
#
# @yields {|key, value| ...}
# @parameter key [String] The header key.
# @parameter value [String] The header value.
def each(&block)
@fields.each(&block)
end
# @returns [Boolean] Whether the headers include the specified key.
def include? key
self[key] != nil
end
alias key? include?
# @returns [Array] All the keys of the headers.
def keys
self.to_h.keys
end
# Extract the specified keys from the headers.
#
# @parameter keys [Array] The keys to extract.
def extract(keys)
deleted, @fields = @fields.partition do |field|
keys.include?(field.first.downcase)
end
if @indexed
keys.each do |key|
@indexed.delete(key)
end
end
return deleted
end
# Add the specified header key value pair.
#
# @parameter key [String] the header key.
# @parameter value [String] the header value to assign.
def add(key, value)
self[key] = value
end
# 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
# Merge the headers into this instance.
def merge!(headers)
headers.each do |key, value|
self[key] = value
end
return self
end
# Merge the headers into a new instance of {Headers}.
def merge(headers)
self.dup.merge!(headers)
end
# Append the value to the given key. Some values can be appended multiple times, others can only be set once.
#
# @parameter key [String] The header key.
# @parameter value [String] The header value.
def []= key, value
if @indexed
merge_into(@indexed, key.downcase, value)
end
@fields << [key, value]
end
# The policy for various headers, including how they are merged and normalized.
POLICY = {
# Headers which may only be specified once:
"content-type" => false,
"content-disposition" => false,
"content-length" => false,
"user-agent" => false,
"referer" => false,
"host" => false,
"from" => false,
"location" => false,
"max-forwards" => false,
"retry-after" => false,
# Custom headers:
"connection" => Header::Connection,
"cache-control" => Header::CacheControl,
"vary" => Header::Vary,
"priority" => Header::Priority,
# Headers specifically for proxies:
"via" => Split,
"x-forwarded-for" => Split,
# Authorization headers:
"authorization" => Header::Authorization,
"proxy-authorization" => Header::Authorization,
# Cache validations:
"etag" => Header::ETag,
"if-match" => Header::ETags,
"if-none-match" => Header::ETags,
# Headers which may be specified multiple times, but which can't be concatenated:
"www-authenticate" => Multiple,
"proxy-authenticate" => Multiple,
# Custom headers:
"set-cookie" => Header::SetCookie,
"cookie" => Header::Cookie,
# Date headers:
# These headers include a comma as part of the formatting so they can't be concatenated.
"date" => Header::Date,
"expires" => Header::Date,
"last-modified" => Header::Date,
"if-modified-since" => Header::Date,
"if-unmodified-since" => Header::Date,
# Accept headers:
"accept" => Header::Accept,
"accept-charset" => Header::AcceptCharset,
"accept-encoding" => Header::AcceptEncoding,
"accept-language" => Header::AcceptLanguage,
}.tap{|hash| hash.default = Split}
# Delete all header values for the given key, and return the merged value.
#
# @parameter key [String] The header key.
# @returns [String | Array | Object] The merged header value.
def delete(key)
deleted, @fields = @fields.partition do |field|
field.first.downcase == key
end
if deleted.empty?
return nil
end
if @indexed
return @indexed.delete(key)
elsif policy = POLICY[key]
(key, value), *tail = deleted
merged = policy.new(value)
tail.each{|k,v| merged << v}
return merged
else
key, value = deleted.last
return value
end
end
# Merge the value into the hash according to the policy for the given key.
#
# @parameter hash [Hash] The hash to merge into.
# @parameter key [String] The header key.
# @parameter value [String] The raw header value.
protected def merge_into(hash, key, value)
if policy = POLICY[key]
if current_value = hash[key]
current_value << value
else
hash[key] = policy.new(value)
end
else
# We can't merge these, we only expose the last one set.
hash[key] = value
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.
def to_h
@indexed ||= @fields.inject({}) do |hash, (key, value)|
merge_into(hash, key.downcase, value)
hash
end
end
alias as_json to_h
# Inspect the headers.
#
# @returns [String] A string representation of the headers.
def inspect
"#<#{self.class} #{@fields.inspect}>"
end
# Compare this object to another object. May depend on the order of the fields.
#
# @returns [Boolean] Whether the other object is equal to this one.
def == other
case other
when Hash
to_h == other
when Headers
@fields == other.fields
else
@fields == other
end
end
# Used for merging objects into a sequential list of headers. Normalizes header keys and values.
class Merged
include Enumerable
# Construct a merged list of headers.
#
# @parameter *all [Array] An array of all headers to merge.
def initialize(*all)
@all = all
end
# @returns [Array] A list of all headers, in the order they were added, as `[key, value]` pairs.
def fields
each.to_a
end
# @returns [Headers] A new instance of {Headers} containing all the merged headers.
def flatten
Headers.new(fields)
end
# Clear the references to all headers.
def clear
@all.clear
end
# Add a new set of headers to the merged list.
#
# @parameter headers [Headers | Array | Hash] A list of headers to add.
def << headers
@all << headers
return self
end
# Enumerate all headers in the merged list.
#
# @yields {|key, value| ...} The header key and value.
# @parameter key [String] The header key (lower case).
# @parameter value [String] The header value.
def each(&block)
return to_enum unless block_given?
@all.each do |headers|
headers.each do |key, value|
yield key.to_s.downcase, value.to_s
end
end
end
end
end
end
end