Merge branch 'issue-341' into 'master'

response_cache plugin: return cached response from store unless stale

Closes #341

See merge request os85/httpx!382
This commit is contained in:
HoneyryderChuck 2025-04-12 21:45:35 +00:00
commit 32c36bb4ee
14 changed files with 626 additions and 274 deletions

View File

@ -10,12 +10,14 @@ module HTTPX
module ResponseCache
CACHEABLE_VERBS = %w[GET HEAD].freeze
CACHEABLE_STATUS_CODES = [200, 203, 206, 300, 301, 410].freeze
SUPPORTED_VARY_HEADERS = %w[accept accept-encoding accept-language cookie origin].sort.freeze
private_constant :CACHEABLE_VERBS
private_constant :CACHEABLE_STATUS_CODES
class << self
def load_dependencies(*)
require_relative "response_cache/store"
require_relative "response_cache/file_store"
end
def cacheable_response?(response)
@ -32,9 +34,7 @@ module HTTPX
# directive prohibits caching. However, a cache that does not support
# the Range and Content-Range headers MUST NOT cache 206 (Partial
# Content) responses.
response.status != 206 && (
response.headers.key?("etag") || response.headers.key?("last-modified") || response.fresh?
)
response.status != 206
end
def cached_response?(response)
@ -42,15 +42,27 @@ module HTTPX
end
def extra_options(options)
options.merge(response_cache_store: Store.new)
options.merge(
supported_vary_headers: SUPPORTED_VARY_HEADERS,
response_cache_store: :store,
)
end
end
module OptionsMethods
def option_response_cache_store(value)
raise TypeError, "must be an instance of #{Store}" unless value.is_a?(Store)
case value
when :store
Store.new
when :file_store
FileStore.new
else
value
end
end
value
def option_supported_vary_headers(value)
Array(value).sort
end
end
@ -63,13 +75,19 @@ module HTTPX
request = super
return request unless cacheable_request?(request)
@options.response_cache_store.prepare(request)
prepare_cache(request)
request
end
private
def send_request(request, *)
return request if request.response
super
end
def fetch_response(request, *)
response = super
@ -77,41 +95,123 @@ module HTTPX
if ResponseCache.cached_response?(response)
log { "returning cached response for #{request.uri}" }
cached_response = @options.response_cache_store.lookup(request)
response.copy_from_cached(cached_response)
else
@options.response_cache_store.cache(request, response)
response.copy_from_cached!
elsif request.cacheable_verb? && ResponseCache.cacheable_response?(response)
request.options.response_cache_store.set(request, response) unless response.cached?
end
response
end
def prepare_cache(request)
cached_response = request.options.response_cache_store.get(request)
return unless cached_response && match_by_vary?(request, cached_response)
cached_response.body.rewind
if cached_response.fresh?
cached_response = cached_response.dup
cached_response.mark_as_cached!
request.response = cached_response
request.emit(:response, cached_response)
return
end
request.cached_response = cached_response
if !request.headers.key?("if-modified-since") && (last_modified = cached_response.headers["last-modified"])
request.headers.add("if-modified-since", last_modified)
end
if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"]) # rubocop:disable Style/GuardClause
request.headers.add("if-none-match", etag)
end
end
def cacheable_request?(request)
request.cacheable_verb? &&
(
!request.headers.key?("cache-control") || !request.headers.get("cache-control").include?("no-store")
)
end
def match_by_vary?(request, response)
vary = response.vary
return true unless vary
original_request = response.instance_variable_get(:@request)
if vary == %w[*]
request.options.supported_vary_headers.each do |field|
return false unless request.headers[field] == original_request.headers[field]
end
return true
end
vary.all? do |field|
!original_request.headers.key?(field) || request.headers[field] == original_request.headers[field]
end
end
end
module RequestMethods
attr_accessor :cached_response
def initialize(*)
super
@cached_response = nil
end
def merge_headers(*)
super
@response_cache_key = nil
end
def cacheable_verb?
CACHEABLE_VERBS.include?(@verb)
end
def response_cache_key
@response_cache_key ||= Digest::SHA1.hexdigest("httpx-response-cache-#{@verb}-#{@uri}")
@response_cache_key ||= begin
keys = [@verb, @uri]
@options.supported_vary_headers.each do |field|
value = @headers[field]
keys << value if value
end
Digest::SHA1.hexdigest("httpx-response-cache-#{keys.join("-")}")
end
end
end
module ResponseMethods
def copy_from_cached(other)
# 304 responses do not have content-type, which are needed for decoding.
@headers = @headers.class.new(other.headers.merge(@headers))
def initialize(*)
super
@cached = false
end
@body = other.body.dup
def cached?
@cached
end
def mark_as_cached!
@cached = true
end
def copy_from_cached!
cached_response = @request.cached_response
return unless cached_response
# 304 responses do not have content-type, which are needed for decoding.
@headers = @headers.class.new(cached_response.headers.merge(@headers))
@body = cached_response.body.dup
@body.rewind
end
@ -121,6 +221,8 @@ module HTTPX
if cache_control
return false if cache_control.include?("no-cache")
return true if cache_control.include?("immutable")
# check age: max-age
max_age = cache_control.find { |directive| directive.start_with?("s-maxage") }
@ -138,13 +240,13 @@ module HTTPX
begin
expires = Time.httpdate(@headers["expires"])
rescue ArgumentError
return true
return false
end
return (expires - Time.now).to_i.positive?
end
true
false
end
def cache_control
@ -163,7 +265,7 @@ module HTTPX
@vary = begin
return unless @headers.key?("vary")
@headers["vary"].split(/ *, */)
@headers["vary"].split(/ *, */).map(&:downcase)
end
end

View File

@ -0,0 +1,106 @@
# frozen_string_literal: true
require "pathname"
module HTTPX::Plugins
module ResponseCache
class FileStore
CRLF = HTTPX::Connection::HTTP1::CRLF
attr_reader :dir
def initialize(dir = Dir.tmpdir)
@dir = Pathname.new(dir).join("httpx-response-cache")
FileUtils.mkdir_p(@dir)
end
def clear
FileUtils.rm_rf(@dir)
end
def get(request)
path = file_path(request)
return unless File.exist?(path)
File.open(path, mode: File::RDONLY | File::BINARY) do |f|
f.flock(File::Constants::LOCK_SH)
read_from_file(request, f)
end
end
def set(request, response)
path = file_path(request)
file_exists = File.exist?(path)
mode = file_exists ? File::RDWR : File::CREAT | File::Constants::WRONLY
File.open(path, mode: mode | File::BINARY) do |f|
f.flock(File::Constants::LOCK_EX)
if file_exists
cached_response = read_from_file(request, f)
if cached_response
next if cached_response == request.cached_response
cached_response.close
f.truncate(0)
f.rewind
end
end
# cache the response
f << response.status << CRLF
f << response.version << CRLF
response.headers.each do |field, value|
f << field << ":" << value << CRLF
end
f << CRLF
response.body.rewind
::IO.copy_stream(response.body, f)
end
end
private
def file_path(request)
@dir.join(request.response_cache_key)
end
def read_from_file(request, f)
# if it's an empty file
return if f.eof?
status = f.readline.delete_suffix!(CRLF)
version = f.readline.delete_suffix!(CRLF)
headers = {}
while (line = f.readline) != CRLF
line.delete_suffix!(CRLF)
sep_index = line.index(":")
field = line.byteslice(0..(sep_index - 1))
value = line.byteslice((sep_index + 1)..-1)
headers[field] = value
end
response = request.options.response_class.new(request, status, version, headers)
::IO.copy_stream(f, response.body)
response
end
end
end
end

View File

@ -12,80 +12,19 @@ module HTTPX::Plugins
@store_mutex.synchronize { @store.clear }
end
def lookup(request)
responses = _get(request)
return unless responses
responses.find(&method(:match_by_vary?).curry(2)[request])
end
def cached?(request)
lookup(request)
end
def cache(request, response)
return unless request.cacheable_verb? && ResponseCache.cacheable_response?(response)
_set(request, response)
end
def prepare(request)
cached_response = lookup(request)
return unless cached_response
return unless match_by_vary?(request, cached_response)
if !request.headers.key?("if-modified-since") && (last_modified = cached_response.headers["last-modified"])
request.headers.add("if-modified-since", last_modified)
end
if !request.headers.key?("if-none-match") && (etag = cached_response.headers["etag"]) # rubocop:disable Style/GuardClause
request.headers.add("if-none-match", etag)
end
end
private
def match_by_vary?(request, response)
vary = response.vary
return true unless vary
original_request = response.instance_variable_get(:@request)
return request.headers.same_headers?(original_request.headers) if vary == %w[*]
vary.all? do |cache_field|
cache_field.downcase!
!original_request.headers.key?(cache_field) || request.headers[cache_field] == original_request.headers[cache_field]
end
end
def _get(request)
def get(request)
@store_mutex.synchronize do
responses = @store[request.response_cache_key]
return unless responses
responses.select! do |res|
!res.body.closed? && res.fresh?
end
responses
@store[request.response_cache_key]
end
end
def _set(request, response)
def set(request, response)
@store_mutex.synchronize do
responses = (@store[request.response_cache_key] ||= [])
cached_response = @store[request.response_cache_key]
responses.reject! do |res|
res.body.closed? || !res.fresh? || match_by_vary?(request, res)
end
cached_response.close if cached_response
responses << response
@store[request.response_cache_key] = response
end
end
end

View File

@ -71,6 +71,14 @@ module HTTPX
@content_type = nil
end
# dupped initialization
def initialize_dup(orig)
super
# if a response gets dupped, the body handle must also get dupped to prevent
# two responses from using the same file handle to read.
@body = orig.body.dup
end
# closes the respective +@request+ and +@body+.
def close
@request.close

View File

@ -11,6 +11,9 @@ module HTTPX
# Array of encodings contained in the response "content-encoding" header.
attr_reader :encodings
attr_reader :buffer
protected :buffer
# initialized with the corresponding HTTPX::Response +response+ and HTTPX::Options +options+.
def initialize(response, options)
@response = response
@ -148,13 +151,12 @@ module HTTPX
end
def ==(other)
object_id == other.object_id || begin
if other.respond_to?(:read)
_with_same_buffer_pos { FileUtils.compare_stream(@buffer, other) }
else
to_s == other.to_s
end
end
super || case other
when Response::Body
@buffer == other.buffer
else
@buffer = other
end
end
# :nocov:
@ -226,19 +228,6 @@ module HTTPX
@state = nextstate
end
def _with_same_buffer_pos # :nodoc:
return yield unless @buffer && @buffer.respond_to?(:pos)
# @type ivar @buffer: StringIO | Tempfile
current_pos = @buffer.pos
@buffer.rewind
begin
yield
ensure
@buffer.pos = current_pos
end
end
class << self
def initialize_inflater_by_encoding(encoding, response, **kwargs) # :nodoc:
case encoding

View File

@ -7,6 +7,9 @@ require "tempfile"
module HTTPX
# wraps and delegates to an internal buffer, which can be a StringIO or a Tempfile.
class Response::Buffer < SimpleDelegator
attr_reader :buffer
protected :buffer
# initializes buffer with the +threshold_size+ over which the payload gets buffer to a tempfile,
# the initial +bytesize+, and the +encoding+.
def initialize(threshold_size:, bytesize: 0, encoding: Encoding::BINARY)
@ -20,7 +23,14 @@ module HTTPX
def initialize_dup(other)
super
@buffer = other.instance_variable_get(:@buffer).dup
# create new descriptor in READ-ONLY mode
@buffer =
case other.buffer
when StringIO
StringIO.new(other.buffer.string, mode: File::RDONLY)
else
other.buffer.class.new(other.buffer.path, encoding: Encoding::BINARY, mode: File::RDONLY)
end
end
# size in bytes of the buffered content.
@ -46,7 +56,7 @@ module HTTPX
end
when Tempfile
rewind
content = _with_same_buffer_pos { @buffer.read }
content = @buffer.read
begin
content.force_encoding(@encoding)
rescue ArgumentError # ex: unknown encoding name - utf
@ -61,6 +71,30 @@ module HTTPX
@buffer.unlink if @buffer.respond_to?(:unlink)
end
def ==(other)
super || begin
return false unless other.is_a?(Response::Buffer)
if @buffer.nil?
other.buffer.nil?
elsif @buffer.respond_to?(:read) &&
other.respond_to?(:read)
buffer_pos = @buffer.pos
other_pos = other.buffer.pos
@buffer.rewind
other.buffer.rewind
begin
FileUtils.compare_stream(@buffer, other.buffer)
ensure
@buffer.pos = buffer_pos
other.buffer.pos = other_pos
end
else
to_s == other.to_s
end
end
end
private
# initializes the buffer into a StringIO, or turns it into a Tempfile when the threshold
@ -82,15 +116,5 @@ module HTTPX
__setobj__(@buffer)
end
def _with_same_buffer_pos # :nodoc:
current_pos = @buffer.pos
@buffer.rewind
begin
yield
ensure
@buffer.pos = current_pos
end
end
end
end

View File

@ -3,30 +3,23 @@ module HTTPX
module ResponseCache
CACHEABLE_VERBS: Array[verb]
CACHEABLE_STATUS_CODES: Array[Integer]
SUPPORTED_VARY_HEADERS: Array[String]
def self?.cacheable_response?: (::HTTPX::ErrorResponse | (Response & ResponseMethods) response) -> bool
def self?.cacheable_response?: (::HTTPX::ErrorResponse | cacheResponse response) -> bool
def self?.cached_response?: (response response) -> bool
class Store
@store: Hash[String, Array[Response]]
interface _ResponseCacheOptions
def response_cache_store: () -> Store
@store_mutex: Thread::Mutex
def supported_vary_headers: () -> Array[String]
end
def lookup: (Request request) -> Response?
interface _ResponseCacheStore
def get: (cacheRequest request) -> cacheResponse?
def cached?: (Request request) -> boolish
def set: (cacheRequest request, cacheResponse response) -> void
def cache: (Request request, Response response) -> void
def prepare: (Request request) -> void
private
def match_by_vary?: (Request request, Response response) -> bool
def _get: (Request request) -> Array[Response]?
def _set: (Request request, Response response) -> void
def clear: () -> void
end
module InstanceMethods
@ -34,10 +27,18 @@ module HTTPX
def clear_response_cache: () -> void
def cacheable_request?: (Request & RequestMethods request) -> bool
private
def prepare_cache: (cacheRequest request) -> void
def cacheable_request?: (cacheRequest request) -> bool
def match_by_vary?: (cacheRequest request, cacheResponse response) -> bool
end
module RequestMethods
attr_accessor cached_response: cacheResponse?
@response_cache_key: String
def response_cache_key: () -> String
@ -46,7 +47,13 @@ module HTTPX
end
module ResponseMethods
def copy_from_cached: (Response other) -> void
@cache: bool
def cached?: () -> bool
def mark_as_cached!: () -> void
def copy_from_cached!: () -> void
def fresh?: () -> bool
@ -60,6 +67,13 @@ module HTTPX
def date: () -> Time
end
type cacheOptions = Options & _ResponseCacheOptions
type cacheRequest = Request & RequestMethods
type cacheResponse = Response & ResponseMethods
end
type sessionResponseCache = Session & ResponseCache::InstanceMethods

View File

@ -7,14 +7,15 @@ module HTTPX
attr_reader encoding: Encoding
attr_reader encodings: Array[String]
attr_reader buffer: Response::Buffer?
@response: Response
@headers: Headers
@options: Options
@state: :idle | :memory | :buffer | :closed
@window_size: Integer
@length: Integer
@buffer: StringIO | Tempfile | nil
@reader: StringIO | Tempfile | nil
@reader: Response::Buffer?
@inflaters: Array[Transcoder::_Inflater]
def initialize: (Response, Options) -> void
@ -47,7 +48,5 @@ module HTTPX
def decode_chunk: (String chunk) -> String
def transition: (Symbol nextstate) -> void
def _with_same_buffer_pos: [A] () { () -> A } -> A
end
end

View File

@ -1,9 +1,10 @@
module HTTPX
class Response::Buffer
attr_reader buffer: StringIO | Tempfile
@threshold_size: Integer
@bytesize: Integer
@encoding: Encoding
@buffer: StringIO | Tempfile
def initialize: (threshold_size: Integer, ?bytesize: Integer, ?encoding: Encoding) -> void
@ -18,7 +19,5 @@ module HTTPX
private
def try_upgrade_buffer: () -> void
def _with_same_buffer_pos: () { () -> void } -> void
end
end

View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
require_relative "test_helper"
require "httpx/plugins/response_cache/file_store"
class ResponseCacheFileStoreTest < Minitest::Test
include ResponseCacheStoreTests
def test_internal_store_set
request = make_request("GET", "http://store-set/")
assert !File.exist?(store.dir.join(request.response_cache_key))
cached_response(request)
assert File.exist?(store.dir.join(request.response_cache_key))
end
private
def store_class
HTTPX::Plugins::ResponseCache::FileStore
end
def store
super.tap do |st|
st.singleton_class.attr_writer :dir
end
end
def setup
tmpdir = Pathname.new(Dir.tmpdir).join(SecureRandom.alphanumeric)
FileUtils.mkdir_p(tmpdir)
store.dir = tmpdir
end
end

View File

@ -4,137 +4,32 @@ require_relative "test_helper"
require "httpx/plugins/response_cache/store"
class ResponseCacheStoreTest < Minitest::Test
include HTTPX
def test_store_cache
request = make_request("GET", "http://example.com/")
response = cached_response(request)
assert store.lookup(request) == response
assert store.cached?(request)
request2 = make_request("GET", "http://example.com/", headers: { "accept" => "text/plain" })
assert store.lookup(request2) == response
request3 = make_request("POST", "http://example.com/", headers: { "accept" => "text/plain" })
assert store.lookup(request3).nil?
end
def test_store_error_status
request = make_request("GET", "http://example.com/")
_response = cached_response(request, status: 404)
assert !store.cached?(request)
_response = cached_response(request, status: 410)
assert store.cached?(request)
end
def test_store_no_store
request = make_request("GET", "http://example.com/")
_response = cached_response(request, extra_headers: { "cache-control" => "private, no-store" })
assert !store.cached?(request)
end
def test_store_maxage
request = make_request("GET", "http://example.com/")
response = cached_response(request, extra_headers: { "cache-control" => "max-age=2" })
assert store.lookup(request) == response
sleep(3)
assert store.lookup(request).nil?
request2 = make_request("GET", "http://example2.com/")
_response2 = cached_response(request2, extra_headers: { "cache-control" => "no-cache, max-age=2" })
assert store.lookup(request2).nil?
end
def test_store_expires
request = make_request("GET", "http://example.com/")
response = cached_response(request, extra_headers: { "expires" => (Time.now + 2).httpdate })
assert store.lookup(request) == response
sleep(3)
assert store.lookup(request).nil?
request2 = make_request("GET", "http://example2.com/")
cached_response(request2, extra_headers: { "cache-control" => "no-cache", "expires" => (Time.now + 2).httpdate })
assert store.lookup(request2).nil?
request_invalid_expires = make_request("GET", "http://example3.com/")
invalid_expires_response = cached_response(request_invalid_expires, extra_headers: { "expires" => "smthsmth" })
assert store.lookup(request_invalid_expires) == invalid_expires_response
end
def test_store_invalid_date
request_invalid_age = make_request("GET", "http://example4.com/")
response_invalid_age = cached_response(request_invalid_age, extra_headers: { "cache-control" => "max-age=2", "date" => "smthsmth" })
assert store.lookup(request_invalid_age) == response_invalid_age
end
def test_prepare_vary
request = make_request("GET", "http://example.com/", headers: { "accept" => "text/plain" })
cached_response(request, extra_headers: { "vary" => "Accept" })
request2 = make_request("GET", "http://example.com/", headers: { "accept" => "text/html" })
store.prepare(request2)
assert !request2.headers.key?("if-none-match")
request3 = make_request("GET", "http://example.com/", headers: { "accept" => "text/plain" })
store.prepare(request3)
assert request3.headers.key?("if-none-match")
request4 = make_request("GET", "http://example.com/", headers: { "accept" => "text/plain", "user-agent" => "Linux Bowser" })
store.prepare(request4)
assert request4.headers.key?("if-none-match")
end
def test_prepare_vary_asterisk
request = make_request("GET", "http://example.com/", headers: { "accept" => "text/plain" })
cached_response(request, extra_headers: { "vary" => "*" })
request2 = make_request("GET", "http://example.com/", headers: { "accept" => "text/html" })
store.prepare(request2)
assert !request2.headers.key?("if-none-match")
request3 = make_request("GET", "http://example.com/", headers: { "accept" => "text/plain" })
store.prepare(request3)
assert request3.headers.key?("if-none-match")
request4 = make_request("GET", "http://example.com/", headers: { "accept" => "text/plain", "user-agent" => "Linux Bowser" })
store.prepare(request4)
assert !request4.headers.key?("if-none-match")
end
include ResponseCacheStoreTests
def test_internal_store_set
internal_store = store.instance_variable_get(:@store)
request = make_request("GET", "http://example.com/")
response = cached_response(request)
assert internal_store[request.response_cache_key].size == 1
assert internal_store[request.response_cache_key].include?(response)
response1 = cached_response(request)
assert internal_store[request.response_cache_key].size == 1
assert internal_store[request.response_cache_key].include?(response1)
response2 = cached_response(request, extra_headers: { "content-encoding" => "gzip" })
assert internal_store[request.response_cache_key].size == 1
assert internal_store[request.response_cache_key].include?(response2)
assert internal_store.size == 1
assert internal_store[request.response_cache_key] == response
response1 = cached_response(request, extra_headers: { "content-language" => "en" })
assert internal_store.size == 1
assert internal_store[request.response_cache_key] == response1
response2 = cached_response(request, extra_headers: { "content-language" => "en", "vary" => "accept-language" })
assert internal_store.size == 1
assert internal_store[request.response_cache_key] == response2
request.merge_headers("accept-language" => "pt")
response3 = cached_response(request, extra_headers: { "content-language" => "pt", "vary" => "accept-language" }, body: "teste")
assert internal_store.size == 2
assert internal_store[request.response_cache_key] == response3
end
private
def request_class
@request_class ||= HTTPX.plugin(:response_cache).class.default_options.request_class
end
def response_class
@response_class ||= HTTPX.plugin(:response_cache).class.default_options.response_class
end
def store
@store ||= Plugins::ResponseCache::Store.new
end
def make_request(meth, uri, *args)
request_class.new(meth, uri, Options.new, *args)
end
def cached_response(request, status: 200, extra_headers: {})
response = response_class.new(request, status, "2.0", { "date" => Time.now.httpdate, "etag" => "ETAG" }.merge(extra_headers))
store.cache(request, response)
response
def store_class
Plugins::ResponseCache::Store
end
end

View File

@ -25,22 +25,68 @@ module Requests
def test_plugin_response_cache_cache_control
cache_client = HTTPX.plugin(:response_cache)
# cache_control = 2
# cache_control_uri = build_uri("/cache/#{cache_control}")
cache_control_uri = build_uri("/cache")
uncached = cache_client.get(cache_control_uri)
verify_status(uncached, 200)
cached = cache_client.get(cache_control_uri)
verify_status(cached, 304)
assert uncached.body == cached.body
# sleep(2)
# expired = cache_client.get(cache_control_uri)
# verify_status(expired, 200)
end
# assert expired.body != uncached.body
def test_plugin_response_cache_do_not_cache_on_error_status
cache_client = HTTPX.plugin(SessionWithPool).plugin(:response_cache)
store = cache_client.instance_variable_get(:@options).response_cache_store.instance_variable_get(:@store)
response_404 = cache_client.get(build_uri("/status/404"))
verify_status(response_404, 404)
assert !store.value?(response_404)
response_410 = cache_client.get(build_uri("/status/410"))
verify_status(response_410, 410)
assert store.value?(response_410)
end
def test_plugin_response_cache_do_not_store_on_no_store_header
return if origin.start_with?("https")
start_test_servlet(ResponseCacheServer) do |server|
cache_client = HTTPX.plugin(:response_cache)
store = cache_client.instance_variable_get(:@options).response_cache_store.instance_variable_get(:@store)
response = cache_client.get("#{server.origin}/no-store")
verify_status(response, 200)
assert store.empty?, "request should not have been cached with no-store header"
end
end
def test_plugin_response_cache_return_cached_while_fresh
cache_client = HTTPX.plugin(SessionWithPool).plugin(:response_cache)
cache_control_uri = build_uri("/cache/2")
store = cache_client.instance_variable_get(:@options).response_cache_store.instance_variable_get(:@store)
uncached = cache_client.get(cache_control_uri)
verify_status(uncached, 200)
assert cache_client.connection_count == 1, "a request should have been made"
assert store.value?(uncached)
cached = cache_client.get(cache_control_uri)
verify_status(cached, 200)
assert cache_client.connection_count == 1, "no request should have been performed"
assert uncached.body == cached.body, "bodies should have the same value"
assert !uncached.body.eql?(cached.body), "bodies should have different references"
assert store.value?(uncached)
sleep(2)
after_expired = cache_client.get(cache_control_uri)
verify_status(after_expired, 200)
assert cache_client.connection_count == 2, "a conditional request should have been made"
assert !store.value?(uncached)
assert store.value?(after_expired)
end
end
end

View File

@ -0,0 +1,180 @@
# frozen_string_literal: true
module ResponseCacheStoreTests
include HTTPX
def test_store_cache
request = make_request("GET", "http://store-cache/")
response = cached_response(request)
cached_response = store.get(request)
assert cached_response.headers == response.headers
assert cached_response.body == response.body
assert store.get(request)
request2 = make_request("GET", "http://store-cache/")
cached_response2 = store.get(request2)
assert cached_response2
assert cached_response2.headers == response.headers
assert cached_response2.body == response.body
request3 = make_request("POST", "http://store-cache/")
assert store.get(request3).nil?
end
def test_store_prepare_maxage
request = make_request("GET", "http://prepare-maxage/")
response = cached_response(request, extra_headers: { "cache-control" => "max-age=2" })
assert request.response.nil?
prepare(request)
assert request.response
assert request.response.headers == response.headers
assert request.response.body == response.body
assert request.cached_response.nil?
request.instance_variable_set(:@response, nil)
sleep(3)
prepare(request)
assert request.cached_response
assert request.cached_response.headers == response.headers
assert request.cached_response.body == response.body
assert request.response.nil?
request2 = make_request("GET", "http://prepare-cache-2.com/")
cached_response(request2, extra_headers: { "cache-control" => "no-cache, max-age=2" })
prepare(request2)
assert request2.response.nil?
end
def test_store_prepare_expires
request = make_request("GET", "http://prepare-expires/")
response = cached_response(request, extra_headers: { "expires" => (Time.now + 2).httpdate })
assert request.response.nil?
prepare(request)
assert request.response
assert request.response.headers == response.headers
assert request.response.body == response.body
assert request.cached_response.nil?
request.instance_variable_set(:@response, nil)
sleep(3)
prepare(request)
assert request.cached_response
assert request.cached_response.headers == response.headers
assert request.cached_response.body == response.body
assert request.response.nil?
request2 = make_request("GET", "http://prepare-expires-2/")
cached_response(request2, extra_headers: { "cache-control" => "no-cache", "expires" => (Time.now + 2).httpdate })
prepare(request2)
assert request2.response.nil?
request_invalid_expires = make_request("GET", "http://prepare-expires-3/")
_invalid_expires_response = cached_response(request_invalid_expires, extra_headers: { "expires" => "smthsmth" })
prepare(request_invalid_expires)
assert request_invalid_expires.response.nil?
end
def test_store_prepare_invalid_date
request_invalid_age = make_request("GET", "http://prepare-expires-4/")
response_invalid_age = cached_response(request_invalid_age, extra_headers: { "cache-control" => "max-age=2", "date" => "smthsmth" })
prepare(request_invalid_age)
assert request_invalid_age.response
assert request_invalid_age.response.headers == response_invalid_age.headers
assert request_invalid_age.response.body == response_invalid_age.body
end
def test_prepare_vary
request = make_request("GET", "http://prepare-vary/", headers: { "accept" => "text/plain" })
response = cached_response(request, extra_headers: { "vary" => "Accept" })
request2 = make_request("GET", "http://prepare-vary/", headers: { "accept" => "text/html" })
prepare(request2)
assert !request2.headers.key?("if-none-match")
assert request2.cached_response.nil?
request3 = make_request("GET", "http://prepare-vary/", headers: { "accept" => "text/plain" })
prepare(request3)
assert request3.cached_response
assert request3.cached_response.headers == response.headers
assert request3.cached_response.body == response.body
assert request3.headers.key?("if-none-match")
request4 = make_request("GET", "http://prepare-vary/", headers: { "accept" => "text/plain", "user-agent" => "Linux Bowser" })
prepare(request4)
assert request4.cached_response
assert request4.cached_response.headers == response.headers
assert request4.cached_response.body == response.body
assert request4.headers.key?("if-none-match")
end
def test_prepare_vary_asterisk
request = make_request("GET", "http://prepare-vary-asterisk/", headers: { "accept" => "text/plain" })
response = cached_response(request, extra_headers: { "vary" => "*" })
request2 = make_request("GET", "http://prepare-vary-asterisk/", headers: { "accept" => "text/html" })
prepare(request2)
assert request.cached_response.nil?
assert !request2.headers.key?("if-none-match")
request3 = make_request("GET", "http://prepare-vary-asterisk/", headers: { "accept" => "text/plain" })
prepare(request3)
assert request3.cached_response
assert request3.cached_response.headers == response.headers
assert request3.cached_response.body == response.body
assert request3.headers.key?("if-none-match")
request4 = make_request("GET", "http://prepare-vary-asterisk/", headers: { "accept" => "text/plain", "accept-language" => "en" })
prepare(request4)
assert request4.cached_response.nil?
assert !request4.headers.key?("if-none-match")
end
private
def teardown
response_cache_session.clear_response_cache
end
def request_class
@request_class ||= response_cache_session_options.request_class
end
def response_class
@response_class ||= response_cache_session_options.response_class
end
def options_class
@options_class ||= response_cache_session_optionsoptions_class
end
def response_cache_session_options
@response_cache_session_options ||= response_cache_session.class.default_options
end
def response_cache_session
@response_cache_session ||= HTTPX.plugin(:response_cache, response_cache_store: store_class.new)
end
def make_request(meth, uri, *args)
response_cache_session.build_request(meth, uri, *args)
end
def store_class
raise NotImplementedError, "must define a `store_class` method"
end
def store
response_cache_session.class.default_options.response_cache_store
end
def cached_response(request, status: 200, extra_headers: {}, body: "test")
response = response_class.new(request, status, "2.0", { "date" => Time.now.httpdate, "etag" => "ETAG" }.merge(extra_headers))
response.body.write(body)
store.set(request, response)
response
end
def prepare(request)
response_cache_session.send(:prepare_cache, request)
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require_relative "test"
class ResponseCacheServer < TestServer
class NoCacheApp < WEBrick::HTTPServlet::AbstractServlet
def do_GET(_req, res) # rubocop:disable Naming/MethodName
res.status = 200
res.body = "no-cache"
res["Cache-Control"] = "private, no-store"
end
end
def initialize(options = {})
super
mount("/no-store", NoCacheApp)
end
end