diff --git a/lib/http/header_normalizer.rb b/lib/http/header_normalizer.rb new file mode 100644 index 00000000..0dc0ed7e --- /dev/null +++ b/lib/http/header_normalizer.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module HTTP + class HeaderNormalizer + # Matches HTTP header names when in "Canonical-Http-Format" + CANONICAL_NAME_RE = /\A[A-Z][a-z]*(?:-[A-Z][a-z]*)*\z/ + + # Matches valid header field name according to RFC. + # @see http://tools.ietf.org/html/rfc7230#section-3.2 + COMPLIANT_NAME_RE = /\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/ + + MAX_CACHE_SIZE = 200 + + def initialize + @cache = LRUCache.new(MAX_CACHE_SIZE) + end + + # Transforms `name` to canonical HTTP header capitalization + def normalize(name) + @cache[name] ||= normalize_header(name) + end + + private + + # Transforms `name` to canonical HTTP header capitalization + # + # @param [String] name + # @raise [HeaderError] if normalized name does not + # match {COMPLIANT_NAME_RE} + # @return [String] canonical HTTP header name + def normalize_header(name) + return name if CANONICAL_NAME_RE.match?(name) + + normalized = name.split(/[\-_]/).each(&:capitalize!).join("-") + + return normalized if COMPLIANT_NAME_RE.match?(normalized) + + raise HeaderError, "Invalid HTTP header field name: #{name.inspect}" + end + + class LRUCache + def initialize(max_size) + @max_size = max_size + @cache = {} + @order = [] + end + + def get(key) + return unless @cache.key?(key) + + # Move the accessed item to the end of the order array + @order.delete(key) + @order.push(key) + @cache[key] + end + + def set(key, value) + @cache[key] = value + @order.push(key) + + # Maintain cache size + return unless @order.size > @max_size + + oldest = @order.shift + @cache.delete(oldest) + end + + def size + @cache.size + end + + def key?(key) + @cache.key?(key) + end + + def [](key) + get(key) + end + + def []=(key, value) + set(key, value) + end + end + + private_constant :LRUCache + end +end diff --git a/lib/http/headers.rb b/lib/http/headers.rb index 7d48b46f..db5fdf93 100644 --- a/lib/http/headers.rb +++ b/lib/http/headers.rb @@ -5,6 +5,7 @@ require "http/errors" require "http/headers/mixin" require "http/headers/known" +require "http/header_normalizer" module HTTP # HTTP Headers container. @@ -12,13 +13,6 @@ class Headers extend Forwardable include Enumerable - # Matches HTTP header names when in "Canonical-Http-Format" - CANONICAL_NAME_RE = /\A[A-Z][a-z]*(?:-[A-Z][a-z]*)*\z/ - - # Matches valid header field name according to RFC. - # @see http://tools.ietf.org/html/rfc7230#section-3.2 - COMPLIANT_NAME_RE = /\A[A-Za-z0-9!#$%&'*+\-.^_`|~]+\z/ - # Class constructor. def initialize # The @pile stores each header value using a three element array: @@ -219,20 +213,15 @@ def coerce(object) private + class << self + def header_normalizer + @header_normalizer ||= HeaderNormalizer.new + end + end + # Transforms `name` to canonical HTTP header capitalization - # - # @param [String] name - # @raise [HeaderError] if normalized name does not - # match {HEADER_NAME_RE} - # @return [String] canonical HTTP header name def normalize_header(name) - return name if CANONICAL_NAME_RE.match?(name) - - normalized = name.split(/[\-_]/).each(&:capitalize!).join("-") - - return normalized if COMPLIANT_NAME_RE.match?(normalized) - - raise HeaderError, "Invalid HTTP header field name: #{name.inspect}" + self.class.header_normalizer.normalize(name) end # Ensures there is no new line character in the header value diff --git a/spec/lib/http/header_normalizer_spec.rb b/spec/lib/http/header_normalizer_spec.rb new file mode 100644 index 00000000..b975ba61 --- /dev/null +++ b/spec/lib/http/header_normalizer_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +RSpec.describe HTTP::HeaderNormalizer do + subject(:normalizer) { described_class.new } + + describe "#normalize" do + it "normalizes the header" do + expect(normalizer.normalize("content_type")).to eq "Content-Type" + end + + it "caches normalized headers" do + object_id = normalizer.normalize("content_type").object_id + expect(object_id).to eq normalizer.normalize("content_type").object_id + end + + it "only caches up to MAX_CACHE_SIZE headers" do + (1..described_class::MAX_CACHE_SIZE + 1).each do |i| + normalizer.normalize("header#{i}") + end + + expect(normalizer.instance_variable_get(:@cache).size).to eq described_class::MAX_CACHE_SIZE + end + end +end