1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311
|
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 [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 [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_assert_no_unused_interactions? && !options[:skip_no_unused_interactions_assertion]
http_interactions.assert_no_unused_interactions!
end
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, :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
[: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
|