From 868f02452db07ce50de744ea946fdf4c7ca40253 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Wed, 11 Mar 2026 06:43:36 -0700 Subject: [PATCH] Add HTTP caching feature (RFC 7234) Implement a new :caching feature that stores and reuses HTTP responses according to RFC 7234 freshness and validation semantics. Only GET and HEAD responses are cached. Supports Cache-Control (max-age, no-cache, no-store), Expires, ETag / If-None-Match, and Last-Modified / If-Modified-Since for freshness checks and conditional revalidation. Ships with a default in-memory store. Custom stores can be passed via the store option. Closes https://github.com/httprb/http/issues/223. --- .mutant.yml | 1 + CHANGELOG.md | 6 + Steepfile | 1 + lib/http/feature.rb | 1 + lib/http/features/caching.rb | 216 +++++ lib/http/features/caching/entry.rb | 178 ++++ lib/http/features/caching/in_memory_store.rb | 63 ++ sig/http.rbs | 59 ++ test/http/features/caching_test.rb | 951 +++++++++++++++++++ test/http/features/logging_test.rb | 28 + 10 files changed, 1504 insertions(+) create mode 100644 lib/http/features/caching.rb create mode 100644 lib/http/features/caching/entry.rb create mode 100644 lib/http/features/caching/in_memory_store.rb create mode 100644 test/http/features/caching_test.rb diff --git a/.mutant.yml b/.mutant.yml index 37a1ab6d..a77b9b26 100644 --- a/.mutant.yml +++ b/.mutant.yml @@ -25,6 +25,7 @@ matcher: - HTTP::URI* - HTTP::Headers* - HTTP::Redirector* + - HTTP::Features::Caching* - HTTP::Features::RaiseError* - HTTP::Base64* ignore: diff --git a/CHANGELOG.md b/CHANGELOG.md index 006e56e0..ef2ff7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- HTTP caching feature (`HTTP.use(:caching)`) that stores and reuses responses + according to RFC 7234. Supports `Cache-Control` (`max-age`, `no-cache`, + `no-store`), `Expires`, `ETag` / `If-None-Match`, and + `Last-Modified` / `If-Modified-Since` for freshness checks and conditional + revalidation. Ships with a default in-memory store; custom stores can be + passed via `store:` option. Only GET and HEAD responses are cached. ([#223]) - `HTTP.digest_auth(user:, pass:)` for HTTP Digest Authentication (RFC 2617 / RFC 7616). Automatically handles 401 challenges with digest credentials, supporting MD5, SHA-256, MD5-sess, and SHA-256-sess algorithms with diff --git a/Steepfile b/Steepfile index ebc47d2a..bdcc7086 100644 --- a/Steepfile +++ b/Steepfile @@ -12,6 +12,7 @@ target :lib do library "singleton" library "socket" library "tempfile" + library "time" library "timeout" library "securerandom" library "uri" diff --git a/lib/http/feature.rb b/lib/http/feature.rb index cbd76950..50273b6c 100644 --- a/lib/http/feature.rb +++ b/lib/http/feature.rb @@ -78,6 +78,7 @@ def on_error(_request, _error); end require "http/features/auto_inflate" require "http/features/auto_deflate" +require "http/features/caching" require "http/features/digest_auth" require "http/features/instrumentation" require "http/features/logging" diff --git a/lib/http/features/caching.rb b/lib/http/features/caching.rb new file mode 100644 index 00000000..79981355 --- /dev/null +++ b/lib/http/features/caching.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "time" + +require "http/features/caching/entry" +require "http/features/caching/in_memory_store" + +module HTTP + module Features + # HTTP caching feature that stores and reuses responses according to + # RFC 7234. Only GET and HEAD responses are cached. Supports + # `Cache-Control`, `Expires`, `ETag`, and `Last-Modified` for freshness + # checks and conditional revalidation. + # + # @example Basic usage with in-memory cache + # HTTP.use(:caching).get("https://example.com/") + # + # @example With a shared store across requests + # store = HTTP::Features::Caching::InMemoryStore.new + # client = HTTP.use(caching: { store: store }) + # client.get("https://example.com/") + # + class Caching < Feature + CACHEABLE_METHODS = Set.new(%i[get head]).freeze + private_constant :CACHEABLE_METHODS + + # The cache store instance + # + # @example + # feature.store + # + # @return [#lookup, #store] the cache store + # @api public + attr_reader :store + + # Initializes the Caching feature + # + # @example + # Caching.new(store: InMemoryStore.new) + # + # @param store [#lookup, #store] cache store instance + # @return [Caching] + # @api public + def initialize(store: InMemoryStore.new) + @store = store + end + + # Wraps the HTTP exchange with caching logic + # + # Checks the cache before making a request. Returns a cached response + # if fresh; otherwise adds conditional headers and revalidates. Stores + # cacheable responses for future use. + # + # @example + # feature.around_request(request) { |req| perform_exchange(req) } + # + # @param request [HTTP::Request] + # @yield Executes the HTTP exchange + # @yieldreturn [HTTP::Response] + # @return [HTTP::Response] + # @api public + def around_request(request) + return yield(request) unless cacheable_request?(request) + + entry = store.lookup(request) + + return yield(request) unless entry + + return build_cached_response(entry, request) if entry.fresh? + + response = yield(add_conditional_headers(request, entry)) + + return revalidate_entry(entry, response, request) if response.code == 304 + + response + end + + # Stores cacheable responses in the cache + # + # @example + # feature.wrap_response(response) + # + # @param response [HTTP::Response] + # @return [HTTP::Response] + # @api public + def wrap_response(response) + return response unless cacheable_request?(response.request) + return response unless cacheable_response?(response) + + store_and_freeze_response(response) + end + + private + + # Revalidate a cached entry with a 304 response + # @return [HTTP::Response] + # @api private + def revalidate_entry(entry, response, request) + entry.update_headers!(response.headers) + entry.revalidate! + build_cached_response(entry, request) + end + + # Store response in cache and return a new response with eagerly-read body + # @return [HTTP::Response] + # @api private + def store_and_freeze_response(response) + body_string = String(response) + store.store(response.request, build_entry(response, body_string)) + + Response.new( + status: response.code, + version: response.version, + headers: response.headers, + proxy_headers: response.proxy_headers, + body: body_string, + request: response.request + ) + end + + # Build a cache entry from a response + # @return [Entry] + # @api private + def build_entry(response, body_string) + Entry.new( + status: response.code, + version: response.version, + headers: response.headers.dup, + proxy_headers: response.proxy_headers, + body: body_string, + request_uri: response.uri, + stored_at: now + ) + end + + # Check whether this request method is cacheable + # @return [Boolean] + # @api private + def cacheable_request?(request) + CACHEABLE_METHODS.include?(request.verb) + end + + # Check whether this response is cacheable + # @return [Boolean] + # @api private + def cacheable_response?(response) + return false if response.status < 200 + return false if response.status >= 400 + + directives = parse_cache_control(response.headers) + return false if directives.include?("no-store") + + freshness_info?(response, directives) + end + + # Whether the response carries enough information to determine freshness + # @return [Boolean] + # @api private + def freshness_info?(response, directives) + return true if directives.any? { |d| d.start_with?("max-age=") } + return true if response.headers.include?(Headers::EXPIRES) + return true if response.headers.include?(Headers::ETAG) + + response.headers.include?(Headers::LAST_MODIFIED) + end + + # Parse Cache-Control header into a list of directives + # @return [Array] + # @api private + def parse_cache_control(headers) + String(headers[Headers::CACHE_CONTROL]).downcase.split(",").map(&:strip) + end + + # Add conditional headers from a cached entry to the request + # @return [HTTP::Request] + # @api private + def add_conditional_headers(request, entry) + headers = request.headers.dup + headers[Headers::IF_NONE_MATCH] = entry.headers[Headers::ETAG] # steep:ignore + headers[Headers::IF_MODIFIED_SINCE] = entry.headers[Headers::LAST_MODIFIED] # steep:ignore + + Request.new( + verb: request.verb, + uri: request.uri, + headers: headers, + proxy: request.proxy, + body: request.body, + version: request.version + ) + end + + # Build a response from a cached entry + # @return [HTTP::Response] + # @api private + def build_cached_response(entry, request) + Response.new( + status: entry.status, + version: entry.version, + headers: entry.headers, + proxy_headers: entry.proxy_headers, + body: entry.body, + request: request + ) + end + + # Current time (extracted for testability) + # @return [Time] + # @api private + def now + Time.now + end + + HTTP::Options.register_feature(:caching, self) + end + end +end diff --git a/lib/http/features/caching/entry.rb b/lib/http/features/caching/entry.rb new file mode 100644 index 00000000..47bd0383 --- /dev/null +++ b/lib/http/features/caching/entry.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "time" + +module HTTP + module Features + class Caching < Feature + # A cached response entry with freshness logic + class Entry + # The HTTP status code + # + # @example + # entry.status # => 200 + # + # @return [Integer] the HTTP status code + # @api public + attr_reader :status + + # The HTTP version + # + # @example + # entry.version # => "1.1" + # + # @return [String] the HTTP version + # @api public + attr_reader :version + + # The response headers + # + # @example + # entry.headers + # + # @return [HTTP::Headers] the response headers + # @api public + attr_reader :headers + + # The proxy headers from the original response + # + # @example + # entry.proxy_headers + # + # @return [HTTP::Headers] the proxy headers + # @api public + attr_reader :proxy_headers + + # The response body as a string + # + # @example + # entry.body # => "..." + # + # @return [String] the response body + # @api public + attr_reader :body + + # The URI of the original request + # + # @example + # entry.request_uri + # + # @return [HTTP::URI] the request URI + # @api public + attr_reader :request_uri + + # When the response was stored + # + # @example + # entry.stored_at + # + # @return [Time] when the response was stored + # @api public + attr_reader :stored_at + + # Create a new cache entry + # + # @example + # Entry.new(status: 200, version: "1.1", headers: headers, + # proxy_headers: proxy_headers, body: "hello", + # request_uri: uri, stored_at: Time.now) + # + # @param status [Integer] + # @param version [String] + # @param headers [HTTP::Headers] + # @param proxy_headers [HTTP::Headers] + # @param body [String] + # @param request_uri [HTTP::URI] + # @param stored_at [Time] + # @return [Entry] + # @api public + def initialize(status:, version:, headers:, proxy_headers:, body:, request_uri:, stored_at:) + @status = status + @version = version + @headers = headers + @proxy_headers = proxy_headers + @body = body + @request_uri = request_uri + @stored_at = stored_at + end + + # Whether the cached response is still fresh + # + # @example + # entry.fresh? # => true + # + # @return [Boolean] + # @api public + def fresh? + return false if no_cache? + + ttl = max_age + return age < ttl if ttl + + expires = expires_at + return Time.now < expires if expires + + false + end + + # Reset the stored_at time to now (after successful revalidation) + # + # @example + # entry.revalidate! + # + # @return [Time] + # @api public + def revalidate! + @stored_at = Time.now + end + + # Merge response headers from a 304 revalidation into the stored entry + # + # @example + # entry.update_headers!(response.headers) + # + # @param response_headers [HTTP::Headers] + # @return [void] + # @api public + def update_headers!(response_headers) + response_headers.each { |name, value| @headers[name] = value } # steep:ignore + end + + private + + # Age of the entry in seconds + # @return [Float] + # @api private + def age + Float(Integer(headers[Headers::AGE], exception: false) || 0) + (Time.now - stored_at) + end + + # max-age value from Cache-Control, if present + # @return [Integer, nil] + # @api private + def max_age + match = String(headers[Headers::CACHE_CONTROL]).match(/max-age=(\d+)/) + return unless match + + Integer(match[1]) + end + + # Expiration time from Expires header + # @return [Time, nil] + # @api private + def expires_at + Time.httpdate(String(headers[Headers::EXPIRES])) + rescue ArgumentError + nil + end + + # Whether the Cache-Control includes no-cache + # @return [Boolean] + # @api private + def no_cache? + String(headers[Headers::CACHE_CONTROL]).downcase.include?("no-cache") + end + end + end + end +end diff --git a/lib/http/features/caching/in_memory_store.rb b/lib/http/features/caching/in_memory_store.rb new file mode 100644 index 00000000..9f7d786f --- /dev/null +++ b/lib/http/features/caching/in_memory_store.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module HTTP + module Features + class Caching < Feature + # Simple in-memory cache store backed by a Hash + # + # Cache keys are derived from the request method and URI. + # + # @example + # store = InMemoryStore.new + # store.store(request, entry) + # store.lookup(request) # => entry + # + class InMemoryStore + # Create a new empty in-memory store + # + # @example + # store = InMemoryStore.new + # + # @return [InMemoryStore] + # @api public + def initialize + @cache = {} + end + + # Look up a cached entry for a request + # + # @example + # store.lookup(request) # => Entry or nil + # + # @param request [HTTP::Request] + # @return [Entry, nil] + # @api public + def lookup(request) + @cache[cache_key(request)] + end + + # Store a cache entry for a request + # + # @example + # store.store(request, entry) + # + # @param request [HTTP::Request] + # @param entry [Entry] + # @return [Entry] + # @api public + def store(request, entry) + @cache[cache_key(request)] = entry + end + + private + + # Compute the cache key for a request + # @return [String] + # @api private + def cache_key(request) + format("%s %s", request.verb, request.uri) + end + end + end + end +end diff --git a/sig/http.rbs b/sig/http.rbs index 3ede1a55..b5c3fa83 100644 --- a/sig/http.rbs +++ b/sig/http.rbs @@ -349,6 +349,65 @@ module HTTP def supported_encoding?: (Response response) -> bool end + class Caching < Feature + CACHEABLE_METHODS: Set[Symbol] + + attr_reader store: untyped + + def initialize: (?store: untyped) -> void + def around_request: (Request request) { (Request) -> Response } -> Response + def wrap_response: (Response response) -> Response + + private + + def revalidate_entry: (Entry entry, Response response, Request request) -> Response + def store_and_freeze_response: (Response response) -> Response + def build_entry: (Response response, String body_string) -> Entry + def cacheable_request?: (Request request) -> bool + def cacheable_response?: (Response response) -> bool + def freshness_info?: (Response response, Array[String] directives) -> bool + def parse_cache_control: (Headers headers) -> Array[String] + def add_conditional_headers: (Request request, Entry entry) -> Request + def build_cached_response: (Entry entry, Request request) -> Response + def now: () -> Time + + public + + class Entry + attr_reader status: Integer + attr_reader version: String + attr_reader headers: Headers + attr_reader proxy_headers: Headers + attr_reader body: String + attr_reader request_uri: URI + attr_reader stored_at: Time + + def initialize: (status: Integer, version: String, headers: Headers, proxy_headers: Headers, body: String, request_uri: URI, stored_at: Time) -> void + def fresh?: () -> bool + def revalidate!: () -> Time + def update_headers!: (Headers response_headers) -> void + + private + + def age: () -> Float + def max_age: () -> Integer? + def expires_at: () -> Time? + def no_cache?: () -> bool + end + + class InMemoryStore + @cache: Hash[String, Entry] + + def initialize: () -> void + def lookup: (Request request) -> Entry? + def store: (Request request, Entry entry) -> Entry + + private + + def cache_key: (Request request) -> String + end + end + class Instrumentation < Feature attr_reader instrumenter: untyped attr_reader name: String diff --git a/test/http/features/caching_test.rb b/test/http/features/caching_test.rb new file mode 100644 index 00000000..4c5ee13c --- /dev/null +++ b/test/http/features/caching_test.rb @@ -0,0 +1,951 @@ +# frozen_string_literal: true + +require "test_helper" + +# A minimal stream that yields content then raises EOFError +class SimpleStream + def initialize(content) + @content = content + @read = false + end + + def readpartial(*) + raise EOFError if @read + + @read = true + @content + end +end + +describe HTTP::Features::Caching do + cover "HTTP::Features::Caching*" + + let(:store) { HTTP::Features::Caching::InMemoryStore.new } + let(:feature) { HTTP::Features::Caching.new(store: store) } + + let(:request) do + HTTP::Request.new(verb: :get, uri: "https://example.com/resource") + end + + let(:post_request) do + HTTP::Request.new(verb: :post, uri: "https://example.com/resource", body: "data") + end + + let(:head_request) do + HTTP::Request.new(verb: :head, uri: "https://example.com/resource") + end + + def make_response(status: 200, headers: {}, body: "hello", req: request, version: "1.1", + proxy_headers: { "X-Proxy" => "true" }) + HTTP::Response.new( + status: status, + version: version, + headers: headers, + proxy_headers: proxy_headers, + body: body, + request: req + ) + end + + def make_streaming_response(status: 200, headers: {}, content: "hello", req: request, version: "1.1") + HTTP::Response.new( + status: status, + version: version, + headers: headers, + connection: SimpleStream.new(content), + request: req + ) + end + + describe "#initialize" do + it "uses InMemoryStore by default" do + default_feature = HTTP::Features::Caching.new + + assert_instance_of HTTP::Features::Caching::InMemoryStore, default_feature.store + end + + it "accepts a custom store" do + assert_same store, feature.store + end + + it "is a Feature subclass" do + caching = HTTP::Features::Caching.new + + assert_kind_of HTTP::Feature, caching + end + end + + describe "#around_request" do + it "yields the original request for non-GET/HEAD requests" do + response = make_response(req: post_request) + yielded_request = nil + result = feature.around_request(post_request) do |req| + yielded_request = req + response + end + + assert_same response, result + assert_same post_request, yielded_request + end + + it "does not consult the store for non-cacheable methods" do + # Even if the store somehow has an entry for a POST, it should not be used + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("Cache-Control" => "max-age=3600"), + proxy_headers: HTTP::Headers.coerce({}), + body: "cached", + request_uri: post_request.uri, + stored_at: Time.now + ) + store.store(post_request, entry) + + response = make_response(req: post_request, body: "fresh") + result = feature.around_request(post_request) { response } + + assert_same response, result + end + + it "yields the original request when no cache entry exists" do + response = make_response + yielded_request = nil + result = feature.around_request(request) do |req| + yielded_request = req + response + end + + assert_same response, result + assert_same request, yielded_request + end + + context "with a fresh cached entry" do + it "returns cached response without yielding" do + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("Cache-Control" => "max-age=3600"), + proxy_headers: HTTP::Headers.coerce("X-Proxy" => "cached"), + body: "cached body", + request_uri: request.uri, + stored_at: Time.now + ) + store.store(request, entry) + + yielded = false + result = feature.around_request(request) { yielded = true } + + refute yielded + assert_equal 200, result.status.code + assert_equal "cached body", result.body.to_s + assert_equal request.uri, result.request.uri + assert_equal "1.1", result.version + assert_equal "max-age=3600", result.headers["Cache-Control"] + end + + it "preserves proxy_headers in cached response" do + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("Cache-Control" => "max-age=3600"), + proxy_headers: HTTP::Headers.coerce("X-Proxy" => "cached-proxy"), + body: "cached body", + request_uri: request.uri, + stored_at: Time.now + ) + store.store(request, entry) + + result = feature.around_request(request) { raise "should not yield" } + + assert_equal "cached-proxy", result.proxy_headers["X-Proxy"] + end + end + + context "with a stale cached entry" do + it "adds If-None-Match header when entry has ETag" do + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("ETag" => '"abc123"', "Cache-Control" => "max-age=0"), + proxy_headers: HTTP::Headers.coerce({}), + body: "old body", + request_uri: request.uri, + stored_at: Time.now - 100 + ) + store.store(request, entry) + + sent_request = nil + response = make_response(status: 200, body: "new body") + feature.around_request(request) do |req| + sent_request = req + response + end + + assert_equal '"abc123"', sent_request.headers["If-None-Match"] + # Verify the original request headers are not mutated (dup was called) + assert_nil request.headers["If-None-Match"] + end + + it "does not add If-None-Match when entry has no ETag" do + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("Last-Modified" => "Wed, 01 Jan 2025 00:00:00 GMT", + "Cache-Control" => "max-age=0"), + proxy_headers: HTTP::Headers.coerce({}), + body: "old body", + request_uri: request.uri, + stored_at: Time.now - 100 + ) + store.store(request, entry) + + sent_request = nil + response = make_response(status: 200, body: "new body") + feature.around_request(request) do |req| + sent_request = req + response + end + + assert_nil sent_request.headers["If-None-Match"] + end + + it "adds If-Modified-Since header when entry has Last-Modified" do + last_mod = "Wed, 01 Jan 2025 00:00:00 GMT" + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("Last-Modified" => last_mod, "Cache-Control" => "max-age=0"), + proxy_headers: HTTP::Headers.coerce({}), + body: "old body", + request_uri: request.uri, + stored_at: Time.now - 100 + ) + store.store(request, entry) + + sent_request = nil + response = make_response(status: 200, body: "new body") + feature.around_request(request) do |req| + sent_request = req + response + end + + assert_equal last_mod, sent_request.headers["If-Modified-Since"] + end + + it "does not add If-Modified-Since when entry has no Last-Modified" do + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0"), + proxy_headers: HTTP::Headers.coerce({}), + body: "old body", + request_uri: request.uri, + stored_at: Time.now - 100 + ) + store.store(request, entry) + + sent_request = nil + response = make_response(status: 200, body: "new body") + feature.around_request(request) do |req| + sent_request = req + response + end + + assert_nil sent_request.headers["If-Modified-Since"] + end + + it "preserves request verb, uri, version, body, and proxy in revalidation request" do + req_with_proxy = HTTP::Request.new( + verb: :get, + uri: "https://example.com/resource", + body: "request body", + version: "1.0", + proxy: { proxy_host: "proxy.example.com", proxy_port: 8080 } + ) + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0"), + proxy_headers: HTTP::Headers.coerce({}), + body: "old body", + request_uri: req_with_proxy.uri, + stored_at: Time.now - 100 + ) + store.store(req_with_proxy, entry) + + sent_request = nil + response = make_response(status: 200, body: "new body", req: req_with_proxy) + feature.around_request(req_with_proxy) do |req| + sent_request = req + response + end + + assert_equal :get, sent_request.verb + assert_equal req_with_proxy.uri, sent_request.uri + assert_equal "1.0", sent_request.version + assert_equal "request body", sent_request.body.source + assert_equal({ proxy_host: "proxy.example.com", proxy_port: 8080 }, sent_request.proxy) + end + + it "returns cached response on 304 and updates stored_at" do + old_stored_at = Time.now - 100 + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0"), + proxy_headers: HTTP::Headers.coerce({}), + body: "cached body", + request_uri: request.uri, + stored_at: old_stored_at + ) + store.store(request, entry) + + not_modified = make_response(status: 304, body: "") + result = feature.around_request(request) { not_modified } + + assert_equal 200, result.status.code + assert_equal "cached body", result.body.to_s + assert_same request, result.request + # Verify revalidate! was called (stored_at updated) + assert_operator entry.stored_at, :>, old_stored_at + end + + it "merges 304 response headers into cached entry" do + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0", + "X-Old" => "preserved"), + proxy_headers: HTTP::Headers.coerce({}), + body: "cached body", + request_uri: request.uri, + stored_at: Time.now - 100 + ) + store.store(request, entry) + + not_modified = make_response( + status: 304, + headers: { "ETag" => '"def"', "X-New" => "added" }, + body: "" + ) + result = feature.around_request(request) { not_modified } + + assert_equal '"def"', result.headers["ETag"] + assert_equal "added", result.headers["X-New"] + assert_equal "preserved", result.headers["X-Old"] + end + + it "returns new response on non-304" do + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0"), + proxy_headers: HTTP::Headers.coerce({}), + body: "old body", + request_uri: request.uri, + stored_at: Time.now - 100 + ) + store.store(request, entry) + + new_response = make_response(status: 200, body: "new body") + result = feature.around_request(request) { new_response } + + assert_same new_response, result + end + + it "compares status code as integer, not status object" do + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("ETag" => '"abc"', "Cache-Control" => "max-age=0"), + proxy_headers: HTTP::Headers.coerce({}), + body: "cached body", + request_uri: request.uri, + stored_at: Time.now - 100 + ) + store.store(request, entry) + + # A non-304 response should be returned as-is + ok_response = make_response(status: 200, body: "new body") + result = feature.around_request(request) { ok_response } + + assert_same ok_response, result + end + end + + it "caches HEAD requests" do + entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce("Cache-Control" => "max-age=3600"), + proxy_headers: HTTP::Headers.coerce({}), + body: "", + request_uri: head_request.uri, + stored_at: Time.now + ) + store.store(head_request, entry) + + yielded = false + result = feature.around_request(head_request) { yielded = true } + + refute yielded + assert_equal 200, result.status.code + end + end + + describe "#wrap_response" do + it "stores cacheable responses and returns response with correct properties" do + response = make_response(headers: { "Cache-Control" => "max-age=3600" }) + result = feature.wrap_response(response) + + assert store.lookup(request) + assert_equal 200, result.status.code + assert_equal "1.1", result.version + assert_equal "hello", result.body.to_s + assert_same request, result.request + end + + it "preserves headers in stored response" do + response = make_response(headers: { "Cache-Control" => "max-age=3600", "X-Custom" => "value" }) + result = feature.wrap_response(response) + + assert_equal "value", result.headers["X-Custom"] + end + + it "preserves proxy_headers in stored response" do + response = make_response( + headers: { "Cache-Control" => "max-age=3600" }, + proxy_headers: { "X-Proxy" => "test-value" } + ) + result = feature.wrap_response(response) + + assert_equal "test-value", result.proxy_headers["X-Proxy"] + end + + it "does not store responses with no-store" do + response = make_response(headers: { "Cache-Control" => "no-store" }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "does not store non-cacheable status codes (500)" do + response = make_response(status: 500, headers: { "Cache-Control" => "max-age=60" }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "does not store 400 responses" do + response = make_response(status: 400, headers: { "Cache-Control" => "max-age=60" }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "stores 399 responses" do + response = make_response(status: 399, headers: { "Cache-Control" => "max-age=60" }) + feature.wrap_response(response) + + assert store.lookup(request) + end + + it "does not store 1xx responses" do + response = make_response(status: 100, headers: { "Cache-Control" => "max-age=60" }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "does not store 199 responses" do + response = make_response(status: 199, headers: { "Cache-Control" => "max-age=60" }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "stores 200 responses" do + response = make_response(status: 200, headers: { "Cache-Control" => "max-age=60" }) + feature.wrap_response(response) + + assert store.lookup(request) + end + + it "does not store POST responses" do + response = make_response( + headers: { "Cache-Control" => "max-age=3600" }, + req: post_request + ) + result = feature.wrap_response(response) + + assert_same response, result + assert_nil store.lookup(post_request) + end + + it "returns original response for non-cacheable responses" do + response = make_response(headers: { "Cache-Control" => "no-store" }) + result = feature.wrap_response(response) + + assert_same response, result + end + + it "stores response with ETag" do + response = make_response(headers: { "ETag" => '"v1"' }) + feature.wrap_response(response) + + assert store.lookup(request) + end + + it "stores response with Last-Modified" do + response = make_response(headers: { "Last-Modified" => "Wed, 01 Jan 2025 00:00:00 GMT" }) + feature.wrap_response(response) + + assert store.lookup(request) + end + + it "stores response with Expires" do + response = make_response(headers: { "Expires" => "Thu, 01 Jan 2099 00:00:00 GMT" }) + feature.wrap_response(response) + + assert store.lookup(request) + end + + it "does not store response without freshness info" do + response = make_response(headers: {}) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "does not treat non-max-age directives as freshness info" do + response = make_response(headers: { "Cache-Control" => "public" }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "preserves uri in stored response" do + response = make_response(headers: { "Cache-Control" => "max-age=3600" }) + result = feature.wrap_response(response) + + assert_equal request.uri, result.uri + end + + it "returns a response with string body" do + response = make_response(headers: { "Cache-Control" => "max-age=3600" }, body: "hello") + result = feature.wrap_response(response) + + assert_equal "hello", result.body.to_s + end + + it "eagerly reads streaming body into a string" do + response = make_streaming_response( + headers: { "Cache-Control" => "max-age=3600" }, + content: "streamed content" + ) + result = feature.wrap_response(response) + + assert_instance_of String, result.body + assert_equal "streamed content", result.body + end + + it "stores 301 redirect responses" do + response = make_response( + status: 301, + headers: { "Cache-Control" => "max-age=3600", "Location" => "https://example.com/new" } + ) + feature.wrap_response(response) + + assert store.lookup(request) + end + + it "stores entry with correct status, version, headers, body, and request_uri" do + response = make_response( + status: 200, + headers: { "Cache-Control" => "max-age=3600", "X-Custom" => "val" }, + body: "stored body", + version: "1.0" + ) + feature.wrap_response(response) + + entry = store.lookup(request) + + assert_equal 200, entry.status + assert_equal "1.0", entry.version + assert_equal "val", entry.headers["X-Custom"] + assert_equal "stored body", entry.body + assert_equal request.uri, entry.request_uri + assert_instance_of Time, entry.stored_at + end + + it "does not store no-store responses even when freshness info is present" do + response = make_response(headers: { "Cache-Control" => "no-store, max-age=3600" }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "does not store no-store responses with ETag" do + response = make_response(headers: { "Cache-Control" => "no-store", "ETag" => '"v1"' }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "handles uppercase NO-STORE with freshness info" do + response = make_response(headers: { "Cache-Control" => "NO-STORE", "ETag" => '"v1"' }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "handles Cache-Control with spaces around commas and freshness info" do + response = make_response(headers: { "Cache-Control" => "max-age=3600 , no-store" }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "handles no-store with trailing whitespace before comma" do + response = make_response(headers: { "Cache-Control" => "no-store , max-age=3600" }) + feature.wrap_response(response) + + assert_nil store.lookup(request) + end + + it "dups headers in stored entry to prevent mutation" do + response = make_response(headers: { "Cache-Control" => "max-age=3600", "X-Custom" => "original" }) + feature.wrap_response(response) + + entry = store.lookup(request) + entry.headers["X-Custom"] = "mutated" + + assert_equal "original", response.headers["X-Custom"] + end + + it "stores proxy_headers in entry" do + response = make_response( + headers: { "Cache-Control" => "max-age=3600" }, + proxy_headers: { "X-Proxy" => "stored-proxy" } + ) + feature.wrap_response(response) + + entry = store.lookup(request) + + assert_equal "stored-proxy", entry.proxy_headers["X-Proxy"] + end + + it "stores entry with integer status code" do + response = make_response(status: 200, headers: { "Cache-Control" => "max-age=3600" }) + feature.wrap_response(response) + + entry = store.lookup(request) + + assert_instance_of Integer, entry.status + end + end + + describe "feature registration" do + it "is registered as :caching" do + assert_equal HTTP::Features::Caching, HTTP::Options.available_features[:caching] + end + end +end + +describe HTTP::Features::Caching::Entry do + cover "HTTP::Features::Caching::Entry*" + + def make_entry(headers: {}, stored_at: Time.now) + HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce(headers), + proxy_headers: HTTP::Headers.coerce({}), + body: "body", + request_uri: HTTP::URI.parse("https://example.com/"), + stored_at: stored_at + ) + end + + describe "#fresh?" do + it "is fresh when max-age has not elapsed" do + entry = make_entry(headers: { "Cache-Control" => "max-age=3600" }) + + assert_predicate entry, :fresh? + end + + it "is not fresh when max-age has elapsed" do + entry = make_entry( + headers: { "Cache-Control" => "max-age=60" }, + stored_at: Time.now - 120 + ) + + refute_predicate entry, :fresh? + end + + it "is fresh when Expires is in the future" do + entry = make_entry(headers: { "Expires" => (Time.now + 3600).httpdate }) + + assert_predicate entry, :fresh? + end + + it "is not fresh when Expires is in the past" do + entry = make_entry(headers: { "Expires" => (Time.now - 3600).httpdate }) + + refute_predicate entry, :fresh? + end + + it "is not fresh when no-cache is present" do + entry = make_entry(headers: { "Cache-Control" => "max-age=3600, no-cache" }) + + refute_predicate entry, :fresh? + end + + it "is not fresh when no-cache is present in uppercase" do + entry = make_entry(headers: { "Cache-Control" => "max-age=3600, NO-CACHE" }) + + refute_predicate entry, :fresh? + end + + it "is not fresh without any freshness info" do + entry = make_entry(headers: {}) + + refute_predicate entry, :fresh? + end + + it "accounts for Age header in freshness" do + entry = make_entry(headers: { "Cache-Control" => "max-age=100", "Age" => "90" }) + + assert_predicate entry, :fresh? + end + + it "is not fresh when Age exceeds max-age" do + entry = make_entry(headers: { "Cache-Control" => "max-age=100", "Age" => "200" }) + + refute_predicate entry, :fresh? + end + + it "treats Age as float for precision" do + # Age=99 with max-age=100: fresh because 99.0 + ~0 < 100 + entry = make_entry(headers: { "Cache-Control" => "max-age=100", "Age" => "99" }) + + assert_predicate entry, :fresh? + end + + it "defaults base_age to 0.0 when no Age header" do + # Without Age header, base_age should be 0.0, not 1.0 + # A freshly stored entry with max-age=1 should be fresh + entry = make_entry(headers: { "Cache-Control" => "max-age=1" }) + + assert_predicate entry, :fresh? + end + + it "handles non-numeric Age header gracefully" do + entry = make_entry(headers: { "Cache-Control" => "max-age=3600", "Age" => "abc" }) + + assert_predicate entry, :fresh? + end + + it "treats non-numeric Age as zero for freshness calculation" do + entry = make_entry( + headers: { "Cache-Control" => "max-age=100", "Age" => "abc" }, + stored_at: Time.now - 100.5 + ) + + refute_predicate entry, :fresh? + end + + it "handles invalid Expires gracefully" do + entry = make_entry(headers: { "Expires" => "not-a-date" }) + + refute_predicate entry, :fresh? + end + + it "falls through to Expires when Cache-Control has no max-age" do + entry = make_entry(headers: { + "Cache-Control" => "public", + "Expires" => (Time.now + 3600).httpdate + }) + + assert_predicate entry, :fresh? + end + + it "prefers max-age over Expires when both present" do + # max-age=0 makes it stale even though Expires is in the future + entry = make_entry( + headers: { "Cache-Control" => "max-age=0", "Expires" => (Time.now + 3600).httpdate }, + stored_at: Time.now - 1 + ) + + refute_predicate entry, :fresh? + end + end + + describe "#update_headers!" do + it "merges new headers into the entry" do + entry = make_entry(headers: { "ETag" => '"old"', "X-Keep" => "kept" }) + new_headers = HTTP::Headers.coerce("ETag" => '"new"', "X-Added" => "added") + + entry.update_headers!(new_headers) + + assert_equal '"new"', entry.headers["ETag"] + assert_equal "added", entry.headers["X-Added"] + assert_equal "kept", entry.headers["X-Keep"] + end + + it "overwrites existing headers with 304 values" do + entry = make_entry(headers: { "Cache-Control" => "max-age=60" }) + new_headers = HTTP::Headers.coerce("Cache-Control" => "max-age=120") + + entry.update_headers!(new_headers) + + assert_equal "max-age=120", entry.headers["Cache-Control"] + end + end + + describe "#revalidate!" do + it "resets stored_at to current time" do + old_time = Time.now - 1000 + entry = make_entry(stored_at: old_time) + entry.revalidate! + + assert_operator entry.stored_at, :>, old_time + end + end + + describe "attribute readers" do + it "exposes status" do + entry = make_entry + + assert_equal 200, entry.status + end + + it "exposes version" do + entry = make_entry + + assert_equal "1.1", entry.version + end + + it "exposes body" do + entry = make_entry + + assert_equal "body", entry.body + end + + it "exposes request_uri" do + entry = make_entry + + assert_equal HTTP::URI.parse("https://example.com/"), entry.request_uri + end + + it "exposes proxy_headers" do + entry = make_entry + + assert_instance_of HTTP::Headers, entry.proxy_headers + end + end +end + +describe HTTP::Features::Caching::InMemoryStore do + cover "HTTP::Features::Caching::InMemoryStore*" + + let(:store) { HTTP::Features::Caching::InMemoryStore.new } + + let(:request) do + HTTP::Request.new(verb: :get, uri: "https://example.com/resource") + end + + let(:entry) do + HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce({}), + proxy_headers: HTTP::Headers.coerce({}), + body: "test", + request_uri: request.uri, + stored_at: Time.now + ) + end + + describe "#lookup" do + it "returns nil for unknown requests" do + assert_nil store.lookup(request) + end + + it "returns stored entry" do + store.store(request, entry) + + assert_same entry, store.lookup(request) + end + end + + describe "#store" do + it "stores and retrieves by request method and URI" do + store.store(request, entry) + + assert_same entry, store.lookup(request) + end + + it "stores different entries for different URIs" do + other_request = HTTP::Request.new(verb: :get, uri: "https://example.com/other") + other_entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce({}), + proxy_headers: HTTP::Headers.coerce({}), + body: "other", + request_uri: other_request.uri, + stored_at: Time.now + ) + + store.store(request, entry) + store.store(other_request, other_entry) + + assert_same entry, store.lookup(request) + assert_same other_entry, store.lookup(other_request) + end + + it "stores different entries for different verbs" do + head_request = HTTP::Request.new(verb: :head, uri: "https://example.com/resource") + head_entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce({}), + proxy_headers: HTTP::Headers.coerce({}), + body: "", + request_uri: head_request.uri, + stored_at: Time.now + ) + + store.store(request, entry) + store.store(head_request, head_entry) + + assert_same entry, store.lookup(request) + assert_same head_entry, store.lookup(head_request) + end + + it "replaces existing entry" do + new_entry = HTTP::Features::Caching::Entry.new( + status: 200, + version: "1.1", + headers: HTTP::Headers.coerce({}), + proxy_headers: HTTP::Headers.coerce({}), + body: "updated", + request_uri: request.uri, + stored_at: Time.now + ) + + store.store(request, entry) + store.store(request, new_entry) + + assert_same new_entry, store.lookup(request) + end + + it "finds entry using a different request object with the same verb and uri" do + store.store(request, entry) + same_request = HTTP::Request.new(verb: :get, uri: "https://example.com/resource") + + assert_same entry, store.lookup(same_request) + end + end +end diff --git a/test/http/features/logging_test.rb b/test/http/features/logging_test.rb index 92260e78..2addc57b 100644 --- a/test/http/features/logging_test.rb +++ b/test/http/features/logging_test.rb @@ -57,6 +57,34 @@ end end + describe "logging the request with string header names" do + let(:request) do + HTTP::Request.new( + verb: :post, + uri: "https://example.com/", + headers: { "X-Custom_Header" => "value1", "X-Another.Header" => "value2" }, + body: "hello" + ) + end + + it "preserves original header names without canonicalization" do + feature.wrap_request(request) + + expected = <<~OUTPUT + ** INFO ** + > POST https://example.com/ + ** DEBUG ** + X-Custom_Header: value1 + X-Another.Header: value2 + Host: example.com + User-Agent: http.rb/#{HTTP::VERSION} + + hello + OUTPUT + assert_equal expected, logdev.string + end + end + describe "logging the request with non-loggable IO body" do let(:request) do HTTP::Request.new(