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] 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 [Array] 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, :clean_outdated_http_interactions ] 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].each do |name| instance_variable_set("@#{name}", @options[name]) end assign_tags @record_mode = @options[:record] @serializer = VCR.cassette_serializers[@options[:serialize_with]] @persister = VCR.cassette_persisters[@options[:persist_with]] @record_mode = :all if should_re_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? return false unless @re_record_interval return false unless originally_recorded_at 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_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 up_to_date_interactions(old_interactions) + new_recorded_interactions 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