require 'faraday'
require 'faraday/http_cache/storage'
require 'faraday/http_cache/response'
module Faraday
# Public: The middleware responsible for caching and serving responses.
# The middleware use the provided configuration options to establish a
# 'Faraday::HttpCache::Storage' to cache responses retrieved by the stack
# adapter. If a stored response can be served again for a subsequent
# request, the middleware will return the response instead of issuing a new
# request to it's server. This middleware should be the last attached handler
# to your stack, so it will be closest to the inner app, avoiding issues
# with other middlewares on your stack.
#
# Examples:
#
# # Using the middleware with a simple client:
# client = Faraday.new do |builder|
# builder.user :http_cache, store: my_store_backend
# builder.adapter Faraday.default_adapter
# end
#
# # Attach a Logger to the middleware.
# client = Faraday.new do |builder|
# builder.use :http_cache, logger: my_logger_instance, store: my_store_backend
# builder.adapter Faraday.default_adapter
# end
#
# # Provide an existing CacheStore (for instance, from a Rails app)
# client = Faraday.new do |builder|
# builder.use :http_cache, store: Rails.cache
# end
#
# # Use Marshal for serialization
# client = Faraday.new do |builder|
# builder.use :http_cache, store: Rails.cache, serializer: Marshal
# end
class HttpCache < Faraday::Middleware
# Internal: valid options for the 'initialize' configuration Hash.
VALID_OPTIONS = [:store, :serializer, :logger, :store_options, :shared_cache]
# Public: Initializes a new HttpCache middleware.
#
# app - the next endpoint on the 'Faraday' stack.
# args - aditional options to setup the logger and the storage.
# :logger - A logger object.
# :serializer - A serializer that should respond to 'dump' and 'load'.
# :shared_cache - A flag to mark the middleware as a shared cache or not.
# :store - A cache store that should respond to 'read' and 'write'.
# :store_options - Deprecated: additional options to setup the cache store.
#
# Examples:
#
# # Initialize the middleware with a logger.
# Faraday::HttpCache.new(app, logger: my_logger)
#
# # Initialize the middleware with a logger and Marshal as a serializer
# Faraday:HttpCache.new(app, logger: my_logger, serializer: Marshal)
#
# # Initialize the middleware with a FileStore at the 'tmp' dir.
# store = ActiveSupport::Cache.lookup_store(:file_store, ['tmp'])
# Faraday::HttpCache.new(app, store: store)
#
# # Initialize the middleware with a MemoryStore and logger
# store = ActiveSupport::Cache.lookup_store
# Faraday::HttpCache.new(app, store: store, logger: my_logger)
def initialize(app, *args)
super(app)
@logger = nil
@shared_cache = true
if args.first.is_a? Hash
options = args.first
@logger = options[:logger]
@shared_cache = options.fetch(:shared_cache, true)
else
options = parse_deprecated_options(*args)
end
assert_valid_options!(options)
@storage = Storage.new(options)
end
# Public: Process the request into a duplicate of this instance to
# ensure that the internal state is preserved.
def call(env)
dup.call!(env)
end
# Internal: Process the stack request to try to serve a cache response.
# On a cacheable request, the middleware will attempt to locate a
# valid stored response to serve. On a cache miss, the middleware will
# forward the request and try to store the response for future requests.
# If the request can't be cached, the request will be delegated directly
# to the underlying app and does nothing to the response.
# The processed steps will be recorded to be logged once the whole
# process is finished.
#
# Returns a 'Faraday::Response' instance.
def call!(env)
@trace = []
@request = create_request(env)
response = nil
if can_cache?(@request[:method])
response = process(env)
else
trace :unacceptable
response = @app.call(env)
end
response.on_complete do
log_request
end
end
# Internal: Should this cache instance act like a "shared cache" according
# to the the definition in RFC 2616?
def shared_cache?
@shared_cache
end
private
# Internal: Receive the deprecated arguments to initialize the old API
# and returns a Hash compatible with the new API
#
# Examples:
#
# parse_deprecated_options(Rails.cache)
# # => { store: Rails.cache }
#
# parse_deprecated_options(:mem_cache_store)
# # => { store: :mem_cache_store }
#
# parse_deprecated_options(:mem_cache_store, logger: Rails.logger)
# # => { store: :mem_cache_store, logger: Rails.logger }
#
# parse_deprecated_options(:mem_cache_store, 'localhost:11211')
# # => { store: :mem_cache_store, store_options: ['localhost:11211] }
#
# parse_deprecated_options(:mem_cache_store, logger: Rails.logger, serializer: Marshal)
# # => { store: :mem_cache_store, logger: Rails.logger, serializer: Marshal }
#
# parse_deprecated_options(serializer: Marshal)
# # => { serializer: Marshal }
#
# parse_deprecated_options(:file_store, { serializer: Marshal }, 'tmp')
# # => { store: :file_store, serializer: Marshal, store_options: ['tmp'] }
#
# parse_deprecated_options(:memory_store, size: 1024)
# # => { store: :memory_store, store_options: [size: 1024] }
#
# Returns a hash with the following keys:
# - store
# - serializer
# - logger
# - store_options
#
# In order to check what each key means, check `Storage#initialize` description.
def parse_deprecated_options(*args)
options = {}
if args.length > 0
Kernel.warn('DEPRECATION WARNING: This API is deprecated, refer to the documentation for the new one', caller)
end
options[:store] = args.shift
if args.first.is_a? Hash
hash_params = args.first
options[:serializer] = hash_params.delete(:serializer)
@logger = hash_params[:logger]
@shared_cache = hash_params.fetch(:shared_cache, true)
end
options[:store_options] = args
options
end
# Internal: Validates if the current request method is valid for caching.
#
# Returns true if the method is ':get' or ':head'.
def can_cache?(method)
method == :get || method == :head
end
# Internal: Tries to locate a valid response or forwards the call to the stack.
# * If no entry is present on the storage, the 'fetch' method will forward
# the call to the remaining stack and return the new response.
# * If a fresh response is found, the middleware will abort the remaining
# stack calls and return the stored response back to the client.
# * If a response is found but isn't fresh anymore, the middleware will
# revalidate the response back to the server.
#
# env - the environment 'Hash' provided from the 'Faraday' stack.
#
# Returns the 'Faraday::Response' instance to be served.
def process(env)
entry = @storage.read(@request)
return fetch(env) if entry.nil?
if entry.fresh?
response = entry.to_response(env)
trace :fresh
else
response = validate(entry, env)
end
response
end
# Internal: Tries to validated a stored entry back to it's origin server
# using the 'If-Modified-Since' and 'If-None-Match' headers with the
# existing 'Last-Modified' and 'ETag' headers. If the new response
# is marked as 'Not Modified', the previous stored response will be used
# and forwarded against the Faraday stack. Otherwise, the freshly new
# response will be stored (replacing the old one) and used.
#
# entry - a stale 'Faraday::HttpCache::Response' retrieved from the cache.
# env - the environment 'Hash' to perform the request.
#
# Returns the 'Faraday::HttpCache::Response' to be forwarded into the stack.
def validate(entry, env)
headers = env[:request_headers]
headers['If-Modified-Since'] = entry.last_modified if entry.last_modified
headers['If-None-Match'] = entry.etag if entry.etag
@app.call(env).on_complete do |requested_env|
response = Response.new(requested_env)
if response.not_modified?
trace :valid
updated_payload = entry.payload
updated_payload[:response_headers].update(response.payload[:response_headers])
requested_env.update(updated_payload)
response = Response.new(updated_payload)
end
store(response)
end
end
# Internal: Records a traced action to be used by the logger once the
# request/response phase is finished.
#
# operation - the name of the performed action, a String or Symbol.
#
# Returns nothing.
def trace(operation)
@trace << operation
end
# Internal: Stores the response into the storage.
# If the response isn't cacheable, a trace action 'invalid' will be
# recorded for logging purposes.
#
# response - a 'Faraday::HttpCache::Response' instance to be stored.
#
# Returns nothing.
def store(response)
if shared_cache? ? response.cacheable_in_shared_cache? : response.cacheable_in_private_cache?
trace :store
@storage.write(@request, response)
else
trace :invalid
end
end
# Internal: Fetches the response from the Faraday stack and stores it.
#
# env - the environment 'Hash' from the Faraday stack.
#
# Returns the fresh 'Faraday::Response' instance.
def fetch(env)
trace :miss
@app.call(env).on_complete do |fresh_env|
response = Response.new(create_response(fresh_env))
store(response)
end
end
# Internal: Creates a new 'Hash' containing the response information.
#
# env - the environment 'Hash' from the Faraday stack.
#
# Returns a 'Hash' containing the ':status', ':body' and 'response_headers'
# entries.
def create_response(env)
hash = env.to_hash
{
status: hash[:status],
body: hash[:body],
response_headers: hash[:response_headers]
}
end
# Internal: Creates a new 'Hash' containing the request information.
#
# env - the environment 'Hash' from the Faraday stack.
#
# Returns a 'Hash' containing the ':method', ':url' and 'request_headers'
# entries.
def create_request(env)
hash = env.to_hash
{
method: hash[:method],
url: hash[:url],
request_headers: hash[:request_headers].dup
}
end
# Internal: Logs the trace info about the incoming request
# and how the middleware handled it.
# This method does nothing if theresn't a logger present.
#
# Returns nothing.
def log_request
return unless @logger
method = @request[:method].to_s.upcase
path = @request[:url].request_uri
line = "HTTP Cache: [#{method} #{path}] #{@trace.join(', ')}"
@logger.debug(line)
end
# Internal: Checks if the given 'options' Hash contains only
# valid keys. Please see the 'VALID_OPTIONS' constant for the
# acceptable keys.
#
# Raises an 'ArgumentError'.
#
# Returns nothing.
def assert_valid_options!(options)
options.each_key do |key|
unless VALID_OPTIONS.include?(key)
raise ArgumentError.new("Unknown option: #{key}. Valid options are :#{VALID_OPTIONS.join(', ')}")
end
end
end
end
end
if Faraday.respond_to?(:register_middleware)
Faraday.register_middleware http_cache: Faraday::HttpCache
elsif Faraday::Middleware.respond_to?(:register_middleware)
Faraday::Middleware.register_middleware http_cache: Faraday::HttpCache
end