diff --git a/lib/httpi/utils.rb b/lib/httpi/utils.rb index ddc16e6..8d177f8 100644 --- a/lib/httpi/utils.rb +++ b/lib/httpi/utils.rb @@ -3,236 +3,93 @@ # library for it. module HTTPI module Utils - # HTTPI::Utils::Headers is a Hash subclass that downcases all keys. + # A case-insensitive Hash that preserves the original case of a + # header when set. + # class Headers < Hash - KNOWN_HEADERS = {} - %w( - Accept-CH - Accept-Patch - Accept-Ranges - Access-Control-Allow-Credentials - Access-Control-Allow-Headers - Access-Control-Allow-Methods - Access-Control-Allow-Origin - Access-Control-Expose-Headers - Access-Control-Max-Age - Age - Allow - Alt-Svc - Cache-Control - Connection - Content-Disposition - Content-Encoding - Content-Language - Content-Length - Content-Location - Content-MD5 - Content-Range - Content-Security-Policy - Content-Security-Policy-Report-Only - Content-Type - Date - Delta-Base - ETag - Expect-CT - Expires - Feature-Policy - IM - Last-Modified - Link - Location - NEL - P3P - Permissions-Policy - Pragma - Preference-Applied - Proxy-Authenticate - Public-Key-Pins - Referrer-Policy - Refresh - Report-To - Retry-After - Server - Set-Cookie - Status - Strict-Transport-Security - Timing-Allow-Origin - Tk - Trailer - Transfer-Encoding - Upgrade - Vary - Via - WWW-Authenticate - Warning - X-Cascade - X-Content-Duration - X-Content-Security-Policy - X-Content-Type-Options - X-Correlation-ID - X-Correlation-Id - X-Download-Options - X-Frame-Options - X-Permitted-Cross-Domain-Policies - X-Powered-By - X-Redirect-By - X-Request-ID - X-Request-Id - X-Runtime - X-UA-Compatible - X-WebKit-CS - X-XSS-Protection - ).each do |str| - downcased = str.downcase.freeze - KNOWN_HEADERS[str] = KNOWN_HEADERS[downcased] = downcased - end - - def self.[](*items) - if items.length % 2 != 0 - if items.length == 1 && items.first.is_a?(Hash) - new.merge!(items.first) - else - raise ArgumentError, "odd number of arguments for Utils::Headers" - end + def self.[](headers) + if headers.is_a?(Headers) && !headers.frozen? + return headers else - hash = new - loop do - break if items.length == 0 - key = items.shift - value = items.shift - hash[key] = value - end - hash + return self.new(headers) end end - def [](key) - super(downcase_key(key)) - end - - def []=(key, value) - super(KNOWN_HEADERS[key] || key.downcase.freeze, value) - end - alias store []= - - def assoc(key) - super(downcase_key(key)) - end - - def compare_by_identity - raise TypeError, "Utils::Headers cannot compare by identity, use regular Hash" - end - - def delete(key) - super(downcase_key(key)) - end - - def dig(key, *a) - super(downcase_key(key), *a) + def initialize(hash = {}) + super() + @names = {} + hash.each { |k, v| self[k] = v } end - def fetch(key, *default, &block) - key = downcase_key(key) + # on dup/clone, we need to duplicate @names hash + def initialize_copy(other) super + @names = other.names.dup end - def fetch_values(*a) - super(*a.map!{|key| downcase_key(key)}) + # on clear, we need to clear @names hash + def clear + super + @names.clear end - def has_key?(key) - super(downcase_key(key)) + def each + super do |k, v| + yield(k, v.respond_to?(:to_ary) ? v.to_ary.join("\n") : v) + end end - alias include? has_key? - alias key? has_key? - alias member? has_key? - def invert - hash = self.class.new - each{|key, value| hash[value] = key} + def to_hash + hash = {} + each { |k, v| hash[k] = v } hash end - def merge(hash, &block) - dup.merge!(hash, &block) + def [](k) + super(k) || super(@names[k.downcase]) end - def reject(&block) - hash = dup - hash.reject!(&block) - hash + def []=(k, v) + canonical = k.downcase.freeze + delete k if @names[canonical] && @names[canonical] != k # .delete is expensive, don't invoke it unless necessary + @names[canonical] = k + super k, v end - def replace(hash) - clear - update(hash) + def delete(k) + canonical = k.downcase + result = super @names.delete(canonical) + result end - def select(&block) - hash = dup - hash.select!(&block) - hash + def include?(k) + super || @names.include?(k.downcase) end - def to_proc - lambda{|x| self[x]} - end + alias_method :has_key?, :include? + alias_method :member?, :include? + alias_method :key?, :include? - def transform_values(&block) - dup.transform_values!(&block) - end - - def update(hash, &block) - hash.each do |key, value| - self[key] = if block_given? && include?(key) - block.call(key, self[key], value) - else - value - end - end + def merge!(other) + other.each { |k, v| self[k] = v } self end - alias merge! update - def values_at(*keys) - keys.map{|key| self[key]} + def merge(other) + hash = dup + hash.merge! other end - # :nocov: - if RUBY_VERSION >= '2.5' - # :nocov: - def slice(*a) - h = self.class.new - a.each{|k| h[k] = self[k] if has_key?(k)} - h - end - - def transform_keys(&block) - dup.transform_keys!(&block) - end - - def transform_keys! - hash = self.class.new - each do |k, v| - hash[yield k] = v - end - replace(hash) - end + def replace(other) + clear + other.each { |k, v| self[k] = v } + self end - # :nocov: - if RUBY_VERSION >= '3.0' - # :nocov: - def except(*a) - super(*a.map!{|key| downcase_key(key)}) + protected + def names + @names end - end - - private - - def downcase_key(key) - key.is_a?(String) ? KNOWN_HEADERS[key] || key.downcase : key - end end end end diff --git a/spec/httpi/response_spec.rb b/spec/httpi/response_spec.rb index 699905e..9e2682a 100644 --- a/spec/httpi/response_spec.rb +++ b/spec/httpi/response_spec.rb @@ -118,6 +118,10 @@ it "returns the HTTP response headers" do expect(response.headers).to eq HTTPI::Utils::Headers.new.merge({ "Content-Type" => "application/dime" }) end + + it "preserves casing" do + expect(response.headers.keys).to eq ["Content-Type"] + end end describe "#body" do