lib/http/response/caching.rb



require "http/cache/headers"
require "http/response/string_body"
require "http/response/io_body"

module HTTP
  class Response
    # Decorator class for responses to provide convenience methods
    # related to caching.
    class Caching < DelegateClass(HTTP::Response)
      CACHEABLE_RESPONSE_CODES = [200, 203, 300, 301, 410].freeze

      def initialize(obj)
        super
        @requested_at = nil
        @received_at  = nil
      end

      # @return [HTTP::Response::Caching]
      def caching
        self
      end

      # @return [Boolean] true iff this response is stale
      def stale?
        expired? || cache_headers.must_revalidate?
      end

      # @returns [Boolean] true iff this response has expired
      def expired?
        current_age >= cache_headers.max_age
      end

      # @return [Boolean] true iff this response is cacheable
      #
      # ---
      # A Vary header field-value of "*" always fails to match and
      # subsequent requests on that resource can only be properly
      # interpreted by the
      def cacheable?
        @cacheable ||=
          begin
            CACHEABLE_RESPONSE_CODES.include?(code) \
              && !(cache_headers.vary_star? ||
                   cache_headers.no_store?  ||
                   cache_headers.no_cache?)
          end
      end

      # @return [Numeric] the current age (in seconds) of this response
      #
      # ---
      # Algo from https://tools.ietf.org/html/rfc2616#section-13.2.3
      def current_age
        now = Time.now
        age_value  = headers.get("Age").map(&:to_i).max || 0

        apparent_age = [0, received_at - server_response_time].max
        corrected_received_age = [apparent_age, age_value].max
        response_delay = [0, received_at - requested_at].max
        corrected_initial_age = corrected_received_age + response_delay
        resident_time = [0, now - received_at].max

        corrected_initial_age + resident_time
      end

      # @return [Time] the time at which this response was requested
      def requested_at
        @requested_at ||= received_at
      end
      attr_writer :requested_at

      # @return [Time] the time at which this response was received
      def received_at
        @received_at ||= Time.now
      end
      attr_writer :received_at

      # Update self based on this response being revalidated by the
      # server.
      def validated!(validating_response)
        headers.merge!(validating_response.headers)
        self.requested_at  = validating_response.requested_at
        self.received_at   = validating_response.received_at
      end

      # @return [HTTP::Cache::Headers] cache control headers helper object.
      def cache_headers
        @cache_headers ||= HTTP::Cache::Headers.new headers
      end

      def body
        @body ||= if __getobj__.body.respond_to? :each
                    __getobj__.body
                  else
                    StringBody.new(__getobj__.body.to_s)
                  end
      end

      def body=(new_body)
        @body = if new_body.respond_to?(:readpartial) && new_body.respond_to?(:read)
                  # IO-ish, probably a rack cache response body
                  IoBody.new(new_body)

                elsif new_body.respond_to? :join
                  # probably an array of body parts (rack cache does this sometimes)
                  StringBody.new(new_body.join(""))

                elsif new_body.respond_to? :readpartial
                  # normal body, just use it.
                  new_body

                else
                  # backstop, just to_s it
                  StringBody.new(new_body.to_s)
                end
      end

      def vary
        headers.get("Vary").first
      end

      protected

      # @return [Time] the time at which the server generated this response.
      def server_response_time
        headers.get("Date").
          map(&method(:to_time_or_epoch)).
          max || begin
                    # set it if it is not already set
                    headers["Date"] = received_at.httpdate
                    received_at
                  end
      end

      def to_time_or_epoch(t_str)
        Time.httpdate(t_str)
      rescue ArgumentError
        Time.at(0)
      end
    end
  end
end