lib/vcr/cassette.rb

Summary

Maintainability
B
5 hrs
Test Coverage
require 'vcr/cassette/http_interaction_list'
require 'vcr/cassette/erb_renderer'
require 'vcr/cassette/serializers'

module VCR
  # The media VCR uses to store HTTP interactions for later re-use.
  class Cassette
    include Logger::Mixin

    # The supported record modes.
    #
    #   * :all -- Record every HTTP interactions; do not play any back.
    #   * :none -- Do not record any HTTP interactions; play them back.
    #   * :new_episodes -- Playback previously recorded HTTP interactions and record new ones.
    #   * :once -- Record the HTTP interactions if the cassette has not already been recorded;
    #              otherwise, playback the HTTP interactions.
    VALID_RECORD_MODES = [:all, :none, :new_episodes, :once]

    # @return [#to_s] The name of the cassette. Used to determine the cassette's file name.
    # @see #file
    attr_reader :name

    # @return [Symbol] The record mode. Determines whether the cassette records HTTP interactions,
    #  plays them back, or does both.
    attr_reader :record_mode

    # @return [Boolean] The cassette's record_on_error mode. When the code that uses the cassette
    #  raises an error (for example a test failure) and record_on_error is set to false, no
    #  cassette will be recorded. This is useful when you are TDD'ing an API integration: when
    #  an error is raised that often means your request is invalid, so you don't want the cassette
    #  to be recorded.
    attr_reader :record_on_error

    # @return [Array<Symbol, #call>] List of request matchers. Used to find a response from an
    #  existing HTTP interaction to play back.
    attr_reader :match_requests_on

    # @return [Boolean, Hash] The cassette's ERB option. The file will be treated as an
    #  ERB template if this has a truthy value. A hash, if provided, will be used as local
    #  variables for the ERB template.
    attr_reader :erb

    # @return [Integer, nil] How frequently (in seconds) the cassette should be re-recorded.
    attr_reader :re_record_interval

    # @return [Boolean, nil] Should outdated interactions be recorded back to file
    attr_reader :clean_outdated_http_interactions

    # @return [Boolean] Should unused requests be dropped from the cassette?
    attr_reader :drop_unused_requests

    # @return [Array<Symbol>] If set, {VCR::Configuration#before_record} and
    #  {VCR::Configuration#before_playback} hooks with a corresponding tag will apply.
    attr_reader :tags

    # @param (see VCR#insert_cassette)
    # @see VCR#insert_cassette
    def initialize(name, options = {})
      @name    = name
      @options = VCR.configuration.default_cassette_options.merge(options)
      @mutex   = Mutex.new

      assert_valid_options!
      extract_options
      raise_error_unless_valid_record_mode

      log "Initialized with options: #{@options.inspect}"
    end

    # Ejects the current cassette. The cassette will no longer be used.
    # In addition, any newly recorded HTTP interactions will be written to
    # disk.
    #
    # @note This is not intended to be called directly. Use `VCR.eject_cassette` instead.
    #
    # @param (see VCR#eject_casssette)
    # @see VCR#eject_cassette
    def eject(options = {})
      write_recorded_interactions_to_disk if should_write_recorded_interactions_to_disk?

      if should_assert_no_unused_interactions? && !options[:skip_no_unused_interactions_assertion]
        http_interactions.assert_no_unused_interactions!
      end
    end

    # @private
    def run_failed!
      @run_failed = true
    end

    # @private
    def run_failed?
      @run_failed = false unless defined?(@run_failed)
      @run_failed
    end

    def should_write_recorded_interactions_to_disk?
      !run_failed? || record_on_error
    end

    # @private
    def http_interactions
      # Without this mutex, under threaded access, an HTTPInteractionList will overwrite
      # the first.
      @mutex.synchronize do
        @http_interactions ||= HTTPInteractionList.new \
          should_stub_requests? ? previously_recorded_interactions : [],
          match_requests_on,
          @allow_playback_repeats,
          @parent_list,
          log_prefix
      end
    end

    # @private
    def record_http_interaction(interaction)
      VCR::CassetteMutex.synchronize do
        log "Recorded HTTP interaction #{request_summary(interaction.request)} => #{response_summary(interaction.response)}"
        new_recorded_interactions << interaction
      end
    end

    # @private
    def new_recorded_interactions
      @new_recorded_interactions ||= []
    end

    # @return [String] The file for this cassette.
    # @raise [NotImplementedError] if the configured cassette persister
    #  does not support resolving file paths.
    # @note VCR will take care of sanitizing the cassette name to make it a valid file name.
    def file
      unless @persister.respond_to?(:absolute_path_to_file)
        raise NotImplementedError, "The configured cassette persister does not support resolving file paths"
      end
      @persister.absolute_path_to_file(storage_key)
    end

    # @return [Boolean] Whether or not the cassette is recording.
    def recording?
      case record_mode
        when :none; false
        when :once; raw_cassette_bytes.to_s.empty?
        else true
      end
    end

    # @return [Hash] The hash that will be serialized when the cassette is written to disk.
    def serializable_hash
      {
        "http_interactions" => interactions_to_record.map(&:to_hash),
        "recorded_with"     => "VCR #{VCR.version}"
      }
    end

    # @return [Time, nil] The `recorded_at` time of the first HTTP interaction
    #                     or nil if the cassette has no prior HTTP interactions.
    #
    # @example
    #
    #   VCR.use_cassette("some cassette") do |cassette|
    #     Timecop.freeze(cassette.originally_recorded_at || Time.now) do
    #       # ...
    #     end
    #   end
    def originally_recorded_at
      @originally_recorded_at ||= previously_recorded_interactions.map(&:recorded_at).min
    end

    # @return [Boolean] false unless wrapped with LinkedCassette
    def linked?
      false
    end

  private

    def assert_valid_options!
      invalid_options = @options.keys - [
        :record, :record_on_error, :erb, :match_requests_on, :re_record_interval, :tag, :tags,
        :update_content_length_header, :allow_playback_repeats, :allow_unused_http_interactions,
        :exclusive, :serialize_with, :preserve_exact_body_bytes, :decode_compressed_response,
        :recompress_response, :persist_with, :persister_options, :clean_outdated_http_interactions,
        :drop_unused_requests
      ]

      if invalid_options.size > 0
        raise ArgumentError.new("You passed the following invalid options to VCR::Cassette.new: #{invalid_options.inspect}.")
      end
    end

    def extract_options
      [:record_on_error, :erb, :match_requests_on, :re_record_interval, :clean_outdated_http_interactions,
       :allow_playback_repeats, :allow_unused_http_interactions, :exclusive, :drop_unused_requests].each do |name|
        instance_variable_set("@#{name}", @options[name])
      end

      assign_tags

      @serializer  = VCR.cassette_serializers[@options[:serialize_with]]
      @persister   = VCR.cassette_persisters[@options[:persist_with]]
      @record_mode = should_re_record?(@options[:record]) ? :all : @options[:record]
      @parent_list = @exclusive ? HTTPInteractionList::NullList : VCR.http_interactions
    end

    def assign_tags
      @tags = Array(@options.fetch(:tags) { @options[:tag] })

      [:update_content_length_header, :preserve_exact_body_bytes, :decode_compressed_response, :recompress_response].each do |tag|
        @tags << tag if @options[tag]
      end
    end

    def previously_recorded_interactions
      @previously_recorded_interactions ||= if !raw_cassette_bytes.to_s.empty?
        deserialized_hash['http_interactions'].map { |h| HTTPInteraction.from_hash(h) }.tap do |interactions|
          invoke_hook(:before_playback, interactions)

          interactions.reject! do |i|
            i.request.uri.is_a?(String) && VCR.request_ignorer.ignore?(i.request)
          end
        end
      else
        []
      end
    end

    def storage_key
      @storage_key ||= [name, @serializer.file_extension].join('.')
    end

    def raise_error_unless_valid_record_mode
      unless VALID_RECORD_MODES.include?(record_mode)
        raise ArgumentError.new("#{record_mode} is not a valid cassette record mode.  Valid modes are: #{VALID_RECORD_MODES.inspect}")
      end
    end

    def should_re_record?(record_mode)
      return false unless @re_record_interval
      return false unless originally_recorded_at
      return false if record_mode == :none

      now = Time.now

      (originally_recorded_at + @re_record_interval < now).tap do |value|
        info = "previously recorded at: '#{originally_recorded_at}'; now: '#{now}'; interval: #{@re_record_interval} seconds"

        if !value
          log "Not re-recording since the interval has not elapsed (#{info})."
        elsif InternetConnection.available?
          log "re-recording (#{info})."
        else
          log "Not re-recording because no internet connection is available (#{info})."
          return false
        end
      end
    end

    def should_stub_requests?
      record_mode != :all
    end

    def should_remove_matching_existing_interactions?
      record_mode == :all
    end

    def should_remove_unused_interactions?
      @drop_unused_requests
    end

    def should_assert_no_unused_interactions?
      !(@allow_unused_http_interactions || $!)
    end

    def raw_cassette_bytes
      @raw_cassette_bytes ||= VCR::Cassette::ERBRenderer.new(@persister[storage_key], erb, name).render
    end

    def merged_interactions
      old_interactions = previously_recorded_interactions

      if should_remove_matching_existing_interactions?
        new_interaction_list = HTTPInteractionList.new(new_recorded_interactions, match_requests_on)
        old_interactions = old_interactions.reject do |i|
          new_interaction_list.response_for(i.request)
        end
      end

      if should_remove_unused_interactions?
        new_recorded_interactions
      else
        up_to_date_interactions(old_interactions) + new_recorded_interactions
      end
    end

    def up_to_date_interactions(interactions)
      return interactions unless clean_outdated_http_interactions && re_record_interval
      interactions.take_while { |x| x[:recorded_at] > Time.now - re_record_interval }
    end

    def interactions_to_record
      # We deep-dup the interactions by roundtripping them to/from a hash.
      # This is necessary because `before_record` can mutate the interactions.
      merged_interactions.map { |i| HTTPInteraction.from_hash(i.to_hash) }.tap do |interactions|
        invoke_hook(:before_record, interactions)
      end
    end

    def write_recorded_interactions_to_disk
      return if new_recorded_interactions.none?
      hash = serializable_hash
      return if hash["http_interactions"].none?

      @persister[storage_key] = @serializer.serialize(hash)
    end

    def invoke_hook(type, interactions)
      interactions.delete_if do |i|
        i.hook_aware.tap do |hw|
          VCR.configuration.invoke_hook(type, hw, self)
        end.ignored?
      end
    end

    def deserialized_hash
      @deserialized_hash ||= @serializer.deserialize(raw_cassette_bytes).tap do |hash|
        unless hash.is_a?(Hash) && hash['http_interactions'].is_a?(Array)
          raise Errors::InvalidCassetteFormatError.new \
            "#{file} does not appear to be a valid VCR 2.0 cassette. " +
            "VCR 1.x cassettes are not valid with VCR 2.0. When upgrading from " +
            "VCR 1.x, it is recommended that you delete all your existing cassettes and " +
            "re-record them, or use the provided vcr:migrate_cassettes rake task to migrate " +
            "them. For more info, see the VCR upgrade guide."
        end
      end
    end

    def log_prefix
      @log_prefix ||= "[Cassette: '#{name}'] "
    end

    def request_summary(request)
      super(request, match_requests_on)
    end
  end
end