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 3 commits
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
27 changes: 8 additions & 19 deletions lib/http/headers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

require "http/errors"
require "http/headers/mixin"
require "http/headers/normalizer"
require "http/headers/known"

module HTTP
Expand All @@ -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:
Expand Down Expand Up @@ -219,20 +213,15 @@ def coerce(object)

private

class << self
def header_normalizer
@header_normalizer ||= Headers::Normalizer.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
82 changes: 82 additions & 0 deletions lib/http/headers/normalizer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# frozen_string_literal: true

module HTTP
class Headers
class Normalizer
# 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 = Cache.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 Cache
def initialize(max_size)
@max_size = max_size
@cache = {}
end

def get(key)
@cache[key]
end

def set(key, value)
@cache[key] = value

# Maintain cache size
return unless @cache.size > @max_size

oldest_key = @cache.keys.first
@cache.delete(oldest_key)
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 :Cache
end
end
end
24 changes: 24 additions & 0 deletions spec/lib/http/headers/normalizer_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# frozen_string_literal: true

RSpec.describe HTTP::Headers::Normalizer 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