Skip to content

Commit

Permalink
Cache header normalization to reduce object allocation
Browse files Browse the repository at this point in the history
  • Loading branch information
alexcwatt committed Aug 23, 2024
1 parent b74b16c commit 747e315
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 19 deletions.
87 changes: 87 additions & 0 deletions lib/http/header_normalizer.rb
Original file line number Diff line number Diff line change
@@ -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
27 changes: 8 additions & 19 deletions lib/http/headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,14 @@
require "http/errors"
require "http/headers/mixin"
require "http/headers/known"
require "http/header_normalizer"

module HTTP
# HTTP Headers container.
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:
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions spec/lib/http/header_normalizer_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 747e315

Please sign in to comment.