Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cache header normalization to reduce object allocation #789

Merged
merged 6 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
alexcwatt marked this conversation as resolved.
Show resolved Hide resolved
# 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 = []
tarcieri marked this conversation as resolved.
Show resolved Hide resolved
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
Loading