lib/faraday/http_cache/storage.rb



require 'json'
require 'digest/sha1'

module Faraday
  class HttpCache < Faraday::Middleware
    # Internal: A wrapper around an ActiveSupport::CacheStore to store responses.
    #
    # Examples
    #
    #   # Creates a new Storage using a MemCached backend from ActiveSupport.
    #   Faraday::HttpCache::Storage.new(:mem_cache_store)
    #
    #   # Reuse some other instance of an ActiveSupport::Cache::Store object.
    #   Faraday::HttpCache::Storage.new(Rails.cache)
    #
    #   # Creates a new Storage using Marshal for serialization.
    #   Faraday::HttpCache::Storage.new(:memory_store, serializer: Marshal)
    class Storage
      # Public: Gets the underlying cache store object.
      attr_reader :cache

      # Internal: Initialize a new Storage object with a cache backend.
      #
      # options - Storage options (default: {}).
      #           :logger        - A Logger object to be used to emit warnings.
      #           :store         - An cache store object that should
      #                            respond to 'read', 'write', and 'delete'.
      #           :serializer    - A serializer object that should
      #                            respond to 'dump' and 'load'.
      def initialize(options = {})
        @cache = options[:store] || MemoryStore.new
        @serializer = options[:serializer] || JSON
        @logger = options[:logger]
        assert_valid_store!
      end

      # Internal: Store a response inside the cache.
      #
      # request  - A Faraday::HttpCache::::Request instance of the executed HTTP
      #            request.
      # response - The Faraday::HttpCache::Response instance to be stored.
      #
      # Returns nothing.
      def write(request, response)
        key = cache_key_for(request.url)
        entry = serialize_entry(request.serializable_hash, response.serializable_hash)

        entries = cache.read(key) || []
        entries = entries.dup if entries.frozen?

        entries.reject! do |(cached_request, cached_response)|
          response_matches?(request, deserialize_object(cached_request), deserialize_object(cached_response))
        end

        entries << entry

        cache.write(key, entries)
      rescue Encoding::UndefinedConversionError => e
        warn "Response could not be serialized: #{e.message}. Try using Marshal to serialize."
        raise e
      end

      # Internal: Attempt to retrieve an stored response that suits the incoming
      # HTTP request.
      #
      # request  - A Faraday::HttpCache::::Request instance of the incoming HTTP
      #            request.
      # klass    - The Class to be instantiated with the stored response.
      #
      # Returns an instance of 'klass'.
      def read(request, klass = Faraday::HttpCache::Response)
        cache_key = cache_key_for(request.url)
        entries = cache.read(cache_key)
        response = lookup_response(request, entries)

        if response
          klass.new(response)
        end
      end

      def delete(url)
        cache_key = cache_key_for(url)
        cache.delete(cache_key)
      end

      private

      # Internal: Retrieve a response Hash from the list of entries that match
      # the given request.
      #
      # request  - A Faraday::HttpCache::::Request instance of the incoming HTTP
      #            request.
      # entries  - An Array of pairs of Hashes (request, response).
      #
      # Returns a Hash or nil.
      def lookup_response(request, entries)
        if entries
          entries = entries.map { |entry| deserialize_entry(*entry) }
          _, response = entries.find { |req, res| response_matches?(request, req, res) }
          response
        end
      end

      # Internal: Check if a cached response and request matches the given
      # request.
      #
      # request         - A Faraday::HttpCache::::Request instance of the
      #                   current HTTP request.
      # cached_request  - The Hash of the request that was cached.
      # cached_response - The Hash of the response that was cached.
      #
      # Returns true or false.
      def response_matches?(request, cached_request, cached_response)
        request.method.to_s == cached_request[:method].to_s &&
          vary_matches?(cached_response, request, cached_request)
      end

      def vary_matches?(cached_response, request, cached_request)
        headers = Faraday::Utils::Headers.new(cached_response[:response_headers])
        vary = headers['Vary'].to_s

        vary.empty? || (vary != '*' && vary.split(/[\s,]+/).all? do |header|
          request.headers[header] == cached_request[:headers][header]
        end)
      end

      def serialize_entry(*objects)
        objects.map { |object| serialize_object(object) }
      end

      def serialize_object(object)
        @serializer.dump(object)
      end

      def deserialize_entry(*objects)
        objects.map { |object| deserialize_object(object) }
      end

      def deserialize_object(object)
        @serializer.load(object).each_with_object({}) do |(key, value), hash|
          hash[key.to_sym] = value
        end
      end

      # Internal: Computes the cache key for a specific request, taking in
      # account the current serializer to avoid cross serialization issues.
      #
      # url - The request URL.
      #
      # Returns a String.
      def cache_key_for(url)
        prefix = (@serializer.is_a?(Module) ? @serializer : @serializer.class).name
        Digest::SHA1.hexdigest("#{prefix}#{url}")
      end

      # Internal: Checks if the given cache object supports the
      # expect API ('read' and 'write').
      #
      # Raises an 'ArgumentError'.
      #
      # Returns nothing.
      def assert_valid_store!
        unless cache.respond_to?(:read) && cache.respond_to?(:write) && cache.respond_to?(:delete)
          raise ArgumentError.new("#{cache.inspect} is not a valid cache store as it does not responds to 'read', 'write' or 'delete'.")
        end
      end

      def warn(message)
        @logger.warn(message) if @logger
      end
    end

    # Internal: A Hash based store to be used by the 'Storage' class
    # when a 'store' is not provided for the middleware setup.
    class MemoryStore
      def initialize
        @cache = {}
      end

      def read(key)
        @cache[key]
      end

      def delete(key)
        @cache.delete(key)
      end

      def write(key, value)
        @cache[key] = value
      end
    end
  end
end