vcr 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. data/.travis.yml +2 -1
  2. data/CHANGELOG.md +58 -1
  3. data/Gemfile +0 -6
  4. data/README.md +12 -3
  5. data/Rakefile +2 -2
  6. data/features/.nav +2 -0
  7. data/features/cassettes/allow_unused_http_interactions.feature +86 -0
  8. data/features/cassettes/naming.feature +3 -2
  9. data/features/cassettes/persistence.feature +63 -0
  10. data/features/configuration/debug_logging.feature +3 -3
  11. data/features/configuration/ignore_request.feature +1 -1
  12. data/features/hooks/after_http_request.feature +1 -1
  13. data/features/step_definitions/cli_steps.rb +33 -0
  14. data/features/support/env.rb +0 -1
  15. data/lib/vcr.rb +13 -0
  16. data/lib/vcr/cassette.rb +57 -44
  17. data/lib/vcr/cassette/{reader.rb → erb_renderer.rb} +9 -11
  18. data/lib/vcr/cassette/http_interaction_list.rb +18 -0
  19. data/lib/vcr/cassette/persisters.rb +42 -0
  20. data/lib/vcr/cassette/persisters/file_system.rb +60 -0
  21. data/lib/vcr/cassette/serializers.rb +1 -1
  22. data/lib/vcr/cassette/serializers/json.rb +1 -1
  23. data/lib/vcr/cassette/serializers/psych.rb +1 -1
  24. data/lib/vcr/cassette/serializers/syck.rb +1 -1
  25. data/lib/vcr/cassette/serializers/yaml.rb +1 -1
  26. data/lib/vcr/configuration.rb +49 -25
  27. data/lib/vcr/errors.rb +6 -0
  28. data/lib/vcr/library_hooks/excon.rb +1 -1
  29. data/lib/vcr/library_hooks/fakeweb.rb +16 -3
  30. data/lib/vcr/library_hooks/faraday.rb +13 -0
  31. data/lib/vcr/library_hooks/typhoeus.rb +5 -1
  32. data/lib/vcr/library_hooks/webmock.rb +90 -35
  33. data/lib/vcr/middleware/faraday.rb +12 -4
  34. data/lib/vcr/request_handler.rb +10 -2
  35. data/lib/vcr/structs.rb +31 -7
  36. data/lib/vcr/version.rb +1 -1
  37. data/script/ci.sh +14 -0
  38. data/spec/spec_helper.rb +8 -1
  39. data/spec/support/shared_example_groups/hook_into_http_library.rb +31 -1
  40. data/spec/vcr/cassette/{reader_spec.rb → erb_renderer_spec.rb} +15 -21
  41. data/spec/vcr/cassette/http_interaction_list_spec.rb +15 -0
  42. data/spec/vcr/cassette/persisters/file_system_spec.rb +64 -0
  43. data/spec/vcr/cassette/persisters_spec.rb +39 -0
  44. data/spec/vcr/cassette/serializers_spec.rb +4 -3
  45. data/spec/vcr/cassette_spec.rb +65 -41
  46. data/spec/vcr/configuration_spec.rb +33 -2
  47. data/spec/vcr/library_hooks/fakeweb_spec.rb +11 -0
  48. data/spec/vcr/library_hooks/faraday_spec.rb +24 -0
  49. data/spec/vcr/library_hooks/webmock_spec.rb +82 -2
  50. data/spec/vcr/middleware/faraday_spec.rb +32 -0
  51. data/spec/vcr/structs_spec.rb +60 -20
  52. data/spec/vcr/util/hooks_spec.rb +7 -0
  53. data/spec/vcr_spec.rb +7 -0
  54. data/vcr.gemspec +2 -2
  55. metadata +31 -26
  56. data/Guardfile +0 -9
  57. data/script/FullBuildRakeFile +0 -56
  58. data/script/full_build +0 -1
  59. data/script/spec +0 -1
data/lib/vcr.rb CHANGED
@@ -3,6 +3,7 @@ require 'vcr/util/variable_args_block_caller'
3
3
 
4
4
  require 'vcr/cassette'
5
5
  require 'vcr/cassette/serializers'
6
+ require 'vcr/cassette/persisters'
6
7
  require 'vcr/configuration'
7
8
  require 'vcr/deprecations'
8
9
  require 'vcr/errors'
@@ -80,12 +81,19 @@ module VCR
80
81
  # @option options :allow_playback_repeats [Boolean] Whether or not to
81
82
  # allow a single HTTP interaction to be played back multiple times.
82
83
  # Defaults to false.
84
+ # @options options :allow_unused_http_interactions [Boolean] If set to
85
+ # false, an error will be raised if a cassette is ejected before all
86
+ # previously recorded HTTP interactions have been used.
87
+ # Defaults to true.
83
88
  # @option options :exclusive [Boolean] Whether or not to use only this
84
89
  # cassette and to completely ignore any cassettes in the cassettes stack.
85
90
  # Defaults to false.
86
91
  # @option options :serialize_with [Symbol] Which serializer to use.
87
92
  # Valid values are :yaml, :syck, :psych, :json or any registered
88
93
  # custom serializer. Defaults to :yaml.
94
+ # @option options :persist_with [Symbol] Which cassette persister to
95
+ # use. Defaults to :file_system. You can also register and use a
96
+ # custom persister.
89
97
  # @option options :preserve_exact_body_bytes [Boolean] Whether or not
90
98
  # to base64 encode the bytes of the requests and responses for this cassette
91
99
  # when serializing it. See also `VCR::Configuration#preserve_exact_body_bytes`.
@@ -292,6 +300,11 @@ module VCR
292
300
  @cassette_serializers ||= Cassette::Serializers.new
293
301
  end
294
302
 
303
+ # @private
304
+ def cassette_persisters
305
+ @cassette_persisters ||= Cassette::Persisters.new
306
+ end
307
+
295
308
  # @private
296
309
  def record_http_interaction(interaction)
297
310
  return unless cassette = current_cassette
data/lib/vcr/cassette.rb CHANGED
@@ -1,8 +1,5 @@
1
- require 'fileutils'
2
- require 'erb'
3
-
4
1
  require 'vcr/cassette/http_interaction_list'
5
- require 'vcr/cassette/reader'
2
+ require 'vcr/cassette/erb_renderer'
6
3
  require 'vcr/cassette/serializers'
7
4
 
8
5
  module VCR
@@ -46,35 +43,14 @@ module VCR
46
43
  # @param (see VCR#insert_cassette)
47
44
  # @see VCR#insert_cassette
48
45
  def initialize(name, options = {})
49
- options = VCR.configuration.default_cassette_options.merge(options)
50
- invalid_options = options.keys - [
51
- :record, :erb, :match_requests_on, :re_record_interval, :tag, :tags,
52
- :update_content_length_header, :allow_playback_repeats, :exclusive,
53
- :serialize_with, :preserve_exact_body_bytes, :decode_compressed_response
54
- ]
55
-
56
- if invalid_options.size > 0
57
- raise ArgumentError.new("You passed the following invalid options to VCR::Cassette.new: #{invalid_options.inspect}.")
58
- end
59
-
60
- @name = name
61
- @record_mode = options[:record]
62
- @erb = options[:erb]
63
- @match_requests_on = options[:match_requests_on]
64
- @re_record_interval = options[:re_record_interval]
65
- @tags = Array(options.fetch(:tags) { options[:tag] })
66
- @tags << :update_content_length_header if options[:update_content_length_header]
67
- @tags << :preserve_exact_body_bytes if options[:preserve_exact_body_bytes]
68
- @tags << :decode_compressed_response if options[:decode_compressed_response]
69
- @allow_playback_repeats = options[:allow_playback_repeats]
70
- @exclusive = options[:exclusive]
71
- @serializer = VCR.cassette_serializers[options[:serialize_with]]
72
- @record_mode = :all if should_re_record?
73
- @parent_list = @exclusive ? HTTPInteractionList::NullList : VCR.http_interactions
46
+ @name = name
47
+ @options = VCR.configuration.default_cassette_options.merge(options)
74
48
 
49
+ assert_valid_options!
50
+ extract_options
75
51
  raise_error_unless_valid_record_mode
76
52
 
77
- log "Initialized with options: #{options.inspect}"
53
+ log "Initialized with options: #{@options.inspect}"
78
54
  end
79
55
 
80
56
  # Ejects the current cassette. The cassette will no longer be used.
@@ -82,6 +58,7 @@ module VCR
82
58
  # disk.
83
59
  def eject
84
60
  write_recorded_interactions_to_disk
61
+ http_interactions.assert_no_unused_interactions! unless @allow_unused_http_interactions
85
62
  end
86
63
 
87
64
  # @private
@@ -106,17 +83,21 @@ module VCR
106
83
  end
107
84
 
108
85
  # @return [String] The file for this cassette.
86
+ # @raise [NotImplementedError] if the configured cassette persister
87
+ # does not support resolving file paths.
109
88
  # @note VCR will take care of sanitizing the cassette name to make it a valid file name.
110
89
  def file
111
- return nil unless VCR.configuration.cassette_library_dir
112
- File.join(VCR.configuration.cassette_library_dir, "#{sanitized_name}.#{@serializer.file_extension}")
90
+ unless @persister.respond_to?(:absolute_path_to_file)
91
+ raise NotImplementedError, "The configured cassette persister does not support resolving file paths"
92
+ end
93
+ @persister.absolute_path_to_file(storage_key)
113
94
  end
114
95
 
115
96
  # @return [Boolean] Whether or not the cassette is recording.
116
97
  def recording?
117
98
  case record_mode
118
99
  when :none; false
119
- when :once; file.nil? || !File.size?(file)
100
+ when :once; raw_cassette_bytes.to_s.empty?
120
101
  else true
121
102
  end
122
103
  end
@@ -131,8 +112,44 @@ module VCR
131
112
 
132
113
  private
133
114
 
115
+ def assert_valid_options!
116
+ invalid_options = @options.keys - [
117
+ :record, :erb, :match_requests_on, :re_record_interval, :tag, :tags,
118
+ :update_content_length_header, :allow_playback_repeats, :allow_unused_http_interactions,
119
+ :exclusive, :serialize_with, :preserve_exact_body_bytes, :decode_compressed_response,
120
+ :persist_with
121
+ ]
122
+
123
+ if invalid_options.size > 0
124
+ raise ArgumentError.new("You passed the following invalid options to VCR::Cassette.new: #{invalid_options.inspect}.")
125
+ end
126
+ end
127
+
128
+ def extract_options
129
+ [:erb, :match_requests_on, :re_record_interval,
130
+ :allow_playback_repeats, :allow_unused_http_interactions, :exclusive].each do |name|
131
+ instance_variable_set("@#{name}", @options[name])
132
+ end
133
+
134
+ assign_tags
135
+
136
+ @record_mode = @options[:record]
137
+ @serializer = VCR.cassette_serializers[@options[:serialize_with]]
138
+ @persister = VCR.cassette_persisters[@options[:persist_with]]
139
+ @record_mode = :all if should_re_record?
140
+ @parent_list = @exclusive ? HTTPInteractionList::NullList : VCR.http_interactions
141
+ end
142
+
143
+ def assign_tags
144
+ @tags = Array(@options.fetch(:tags) { @options[:tag] })
145
+
146
+ [:update_content_length_header, :preserve_exact_body_bytes, :decode_compressed_response].each do |tag|
147
+ @tags << tag if @options[tag]
148
+ end
149
+ end
150
+
134
151
  def previously_recorded_interactions
135
- @previously_recorded_interactions ||= if file && File.size?(file)
152
+ @previously_recorded_interactions ||= if !raw_cassette_bytes.to_s.empty?
136
153
  deserialized_hash['http_interactions'].map { |h| HTTPInteraction.from_hash(h) }.tap do |interactions|
137
154
  invoke_hook(:before_playback, interactions)
138
155
 
@@ -145,8 +162,8 @@ module VCR
145
162
  end
146
163
  end
147
164
 
148
- def sanitized_name
149
- name.to_s.gsub(/[^\w\-\/]+/, '_')
165
+ def storage_key
166
+ @storage_key ||= [name, @serializer.file_extension].join('.')
150
167
  end
151
168
 
152
169
  def raise_error_unless_valid_record_mode
@@ -159,7 +176,6 @@ module VCR
159
176
  return false unless @re_record_interval
160
177
  previously_recorded_at = earliest_interaction_recorded_at
161
178
  return false unless previously_recorded_at
162
- return false unless File.exist?(file)
163
179
 
164
180
  now = Time.now
165
181
 
@@ -189,8 +205,8 @@ module VCR
189
205
  record_mode == :all
190
206
  end
191
207
 
192
- def raw_yaml_content
193
- VCR::Cassette::Reader.new(file, erb).read
208
+ def raw_cassette_bytes
209
+ @raw_cassette_bytes ||= VCR::Cassette::ERBRenderer.new(@persister[storage_key], erb, name).render
194
210
  end
195
211
 
196
212
  def merged_interactions
@@ -213,14 +229,11 @@ module VCR
213
229
  end
214
230
 
215
231
  def write_recorded_interactions_to_disk
216
- return unless VCR.configuration.cassette_library_dir
217
232
  return if new_recorded_interactions.none?
218
233
  hash = serializable_hash
219
234
  return if hash["http_interactions"].none?
220
235
 
221
- directory = File.dirname(file)
222
- FileUtils.mkdir_p directory unless File.exist?(directory)
223
- File.open(file, 'w') { |f| f.write @serializer.serialize(hash) }
236
+ @persister[storage_key] = @serializer.serialize(hash)
224
237
  end
225
238
 
226
239
  def invoke_hook(type, interactions)
@@ -232,7 +245,7 @@ module VCR
232
245
  end
233
246
 
234
247
  def deserialized_hash
235
- @deserialized_hash ||= @serializer.deserialize(raw_yaml_content).tap do |hash|
248
+ @deserialized_hash ||= @serializer.deserialize(raw_cassette_bytes).tap do |hash|
236
249
  unless hash.is_a?(Hash) && hash['http_interactions'].is_a?(Array)
237
250
  raise Errors::InvalidCassetteFormatError.new \
238
251
  "#{file} does not appear to be a valid VCR 2.0 cassette. " +
@@ -1,13 +1,15 @@
1
+ require 'erb'
2
+
1
3
  module VCR
2
4
  class Cassette
3
5
  # @private
4
- class Reader
5
- def initialize(file_name, erb)
6
- @file_name, @erb = file_name, erb
6
+ class ERBRenderer
7
+ def initialize(raw_template, erb, cassette_name=nil)
8
+ @raw_template, @erb, @cassette_name = raw_template, erb, cassette_name
7
9
  end
8
10
 
9
- def read
10
- return file_content unless use_erb?
11
+ def render
12
+ return @raw_template if @raw_template.nil? || !use_erb?
11
13
  binding = binding_for_variables if erb_variables
12
14
  template.result(binding)
13
15
  rescue NameError => e
@@ -20,7 +22,7 @@ module VCR
20
22
  example_hash = (erb_variables || {}).merge(e.name => 'some value')
21
23
 
22
24
  raise Errors::MissingERBVariableError.new(
23
- "The ERB in the #{@file_name} cassette file references undefined variable #{e.name}. " +
25
+ "The ERB in the #{@cassette_name} cassette file references undefined variable #{e.name}. " +
24
26
  "Pass it to the cassette using :erb => #{ example_hash.inspect }."
25
27
  )
26
28
  end
@@ -33,12 +35,8 @@ module VCR
33
35
  @erb if @erb.is_a?(Hash)
34
36
  end
35
37
 
36
- def file_content
37
- @file_content ||= File.read(@file_name)
38
- end
39
-
40
38
  def template
41
- @template ||= ERB.new(file_content)
39
+ @template ||= ERB.new(@raw_template)
42
40
  end
43
41
 
44
42
  @@struct_cache = Hash.new do |hash, attributes|
@@ -54,8 +54,26 @@ module VCR
54
54
  @interactions.size
55
55
  end
56
56
 
57
+ # Checks if there are no unused interactions left.
58
+ #
59
+ # @raise [VCR::Errors::UnusedHTTPInteractionError] if not all interactions were played back.
60
+ def assert_no_unused_interactions!
61
+ return unless has_unused_interactions?
62
+
63
+ descriptions = @interactions.map do |i|
64
+ " - #{request_summary(i.request)} => #{response_summary(i.response)}"
65
+ end.join("\n")
66
+
67
+ raise Errors::UnusedHTTPInteractionError, "There are unused HTTP interactions left in the cassette:\n#{descriptions}"
68
+ end
69
+
57
70
  private
58
71
 
72
+ # @return [Boolean] Whether or not there are unused interactions left in the list.
73
+ def has_unused_interactions?
74
+ @interactions.size > 0
75
+ end
76
+
59
77
  def request_summary(request)
60
78
  super(request, @request_matchers)
61
79
  end
@@ -0,0 +1,42 @@
1
+ module VCR
2
+ class Cassette
3
+ # Keeps track of the cassette persisters in a hash-like object.
4
+ class Persisters
5
+ autoload :FileSystem, 'vcr/cassette/persisters/file_system'
6
+
7
+ # @private
8
+ def initialize
9
+ @persisters = {}
10
+ end
11
+
12
+ # Gets the named persister.
13
+ #
14
+ # @param name [Symbol] the name of the persister
15
+ # @return the named persister
16
+ # @raise [ArgumentError] if there is not a persister for the given name
17
+ def [](name)
18
+ @persisters.fetch(name) do |_|
19
+ @persisters[name] = case name
20
+ when :file_system then FileSystem
21
+ else raise ArgumentError, "The requested VCR cassette persister " +
22
+ "(#{name.inspect}) is not registered."
23
+ end
24
+ end
25
+ end
26
+
27
+ # Registers a persister.
28
+ #
29
+ # @param name [Symbol] the name of the persister
30
+ # @param value [#[], #[]=] the persister object. It must implement `[]` and `[]=`.
31
+ def []=(name, value)
32
+ if @persisters.has_key?(name)
33
+ warn "WARNING: There is already a VCR cassette persister " +
34
+ "registered for #{name.inspect}. Overriding it."
35
+ end
36
+
37
+ @persisters[name] = value
38
+ end
39
+
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,60 @@
1
+ require 'fileutils'
2
+
3
+ module VCR
4
+ class Cassette
5
+ class Persisters
6
+ # The only built-in cassette persister. Persists cassettes to the file system.
7
+ module FileSystem
8
+ extend self
9
+
10
+ # @private
11
+ attr_reader :storage_location
12
+
13
+ # @private
14
+ def storage_location=(dir)
15
+ FileUtils.mkdir_p(dir) if dir
16
+ @storage_location = dir ? absolute_path_for(dir) : nil
17
+ end
18
+
19
+ # Gets the cassette for the given storage key (file name).
20
+ #
21
+ # @param [String] file_name the file name
22
+ # @return [String] the cassette content
23
+ def [](file_name)
24
+ path = absolute_path_to_file(file_name)
25
+ return nil unless File.exist?(path)
26
+ File.read(path)
27
+ end
28
+
29
+ # Sets the cassette for the given storage key (file name).
30
+ #
31
+ # @param [String] file_name the file name
32
+ # @param [String] content the content to store
33
+ def []=(file_name, content)
34
+ path = absolute_path_to_file(file_name)
35
+ directory = File.dirname(path)
36
+ FileUtils.mkdir_p(directory) unless File.exist?(directory)
37
+ File.open(path, 'w') { |f| f.write(content) }
38
+ end
39
+
40
+ # @private
41
+ def absolute_path_to_file(file_name)
42
+ return nil unless storage_location
43
+ File.join(storage_location, sanitized_file_name_from(file_name))
44
+ end
45
+
46
+ private
47
+
48
+ def absolute_path_for(path)
49
+ Dir.chdir(path) { Dir.pwd }
50
+ end
51
+
52
+ def sanitized_file_name_from(file_name)
53
+ parts = file_name.to_s.split('.')
54
+ file_extension = '.' + parts.pop if parts.size > 1
55
+ parts.join('.').gsub(/[^\w\-\/]+/, '_') + file_extension.to_s
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -33,7 +33,7 @@ module VCR
33
33
  #
34
34
  # @param name [Symbol] the name of the serializer
35
35
  # @param value [#file_extension, #serialize, #deserialize] the serializer object. It must implement
36
- # +file_extension()+, +serialize(Hash)+ and +deserialize(String)+.
36
+ # `file_extension()`, `serialize(Hash)` and `deserialize(String)`.
37
37
  def []=(name, value)
38
38
  if @serializers.has_key?(name)
39
39
  warn "WARNING: There is already a VCR cassette serializer registered for #{name.inspect}. Overriding it."
@@ -36,7 +36,7 @@ module VCR
36
36
  # Deserializes the given string using `MultiJson`.
37
37
  #
38
38
  # @param [String] string the JSON string
39
- # @param [Hash] hash the deserialized object
39
+ # @return [Hash] the deserialized object
40
40
  def deserialize(string)
41
41
  handle_encoding_errors do
42
42
  MultiJson.decode(string)
@@ -35,7 +35,7 @@ module VCR
35
35
  # Deserializes the given string using Psych.
36
36
  #
37
37
  # @param [String] string the YAML string
38
- # @param [Hash] hash the deserialized object
38
+ # @return [Hash] the deserialized object
39
39
  def deserialize(string)
40
40
  handle_encoding_errors do
41
41
  ::Psych.load(string)
@@ -35,7 +35,7 @@ module VCR
35
35
  # Deserializes the given string using Syck.
36
36
  #
37
37
  # @param [String] string the YAML string
38
- # @param [Hash] hash the deserialized object
38
+ # @return [Hash] the deserialized object
39
39
  def deserialize(string)
40
40
  handle_encoding_errors do
41
41
  using_syck { ::YAML.load(string) }
@@ -37,7 +37,7 @@ module VCR
37
37
  # Deserializes the given string using YAML.
38
38
  #
39
39
  # @param [String] string the YAML string
40
- # @param [Hash] hash the deserialized object
40
+ # @return [Hash] the deserialized object
41
41
  def deserialize(string)
42
42
  handle_encoding_errors do
43
43
  ::YAML.load(string)