require 'base64' require 'delegate' require 'time' module VCR # @private module Normalizers # @private module Body def self.included(klass) klass.extend ClassMethods end # @private module ClassMethods def body_from(hash_or_string) return hash_or_string unless hash_or_string.is_a?(Hash) hash = hash_or_string if hash.has_key?('base64_string') string = Base64.decode64(hash['base64_string']) force_encode_string(string, hash['encoding']) else try_encode_string(hash['string'], hash['encoding']) end end if "".respond_to?(:encoding) def force_encode_string(string, encoding) return string unless encoding string.force_encoding(encoding) end def try_encode_string(string, encoding) return string if encoding.nil? || string.encoding.name == encoding # ASCII-8BIT just means binary, so encoding to it is nonsensical # and yet "\u00f6".encode("ASCII-8BIT") raises an error. # Instead, we'll force encode it (essentially just tagging it as binary) return string.force_encoding(encoding) if encoding == "ASCII-8BIT" string.encode(encoding) rescue EncodingError => e struct_type = name.split('::').last.downcase warn "VCR: got `#{e.class.name}: #{e.message}` while trying to encode the #{string.encoding.name} " + "#{struct_type} body to the original body encoding (#{encoding}). Consider using the " + "`:preserve_exact_body_bytes` option to work around this." return string end else def force_encode_string(string, encoding) string end def try_encode_string(string, encoding) string end end end def initialize(*args) super if body && !body.is_a?(String) raise ArgumentError, "#{self.class} initialized with an invalid (non-String) body of class #{body.class}: #{body.inspect}." end # Ensure that the body is a raw string, in case the string instance # has been subclassed or extended with additional instance variables # or attributes, so that it is serialized to YAML as a raw string. # This is needed for rest-client. See this ticket for more info: # http://github.com/myronmarston/vcr/issues/4 self.body = String.new(body.to_s) end private def serializable_body # Ensure it's just a string, and not a string with some # extra state, as such strings serialize to YAML with # all the extra state. body = String.new(self.body.to_s) if VCR.configuration.preserve_exact_body_bytes_for?(self) base_body_hash(body).merge('base64_string' => Base64.encode64(body)) else base_body_hash(body).merge('string' => body) end end if ''.respond_to?(:encoding) def base_body_hash(body) { 'encoding' => body.encoding.name } end else def base_body_hash(body) { } end end end # @private module Header def initialize(*args) super normalize_headers end private def normalize_headers new_headers = {} @normalized_header_keys = Hash.new {|h,k| k } headers.each do |k, v| val_array = case v when Array then v when nil then [] else [v] end new_headers[String.new(k)] = convert_to_raw_strings(val_array) @normalized_header_keys[k.downcase] = k end if headers self.headers = new_headers end def header_key(key) key = @normalized_header_keys[key.downcase] key if headers.has_key? key end def get_header(key) key = header_key(key) headers[key] if key end def edit_header(key, value = nil) if key = header_key(key) value ||= yield headers[key] headers[key] = Array(value) end end def delete_header(key) if key = header_key(key) @normalized_header_keys.delete key.downcase headers.delete key end end def convert_to_raw_strings(array) # Ensure the values are raw strings. # Apparently for Paperclip uploads to S3, headers # get serialized with some extra stuff which leads # to a seg fault. See this issue for more info: # https://github.com/myronmarston/vcr/issues#issue/39 array.map do |v| case v when String; String.new(v) when Array; convert_to_raw_strings(v) else v end end end end end # The request of an {HTTPInteraction}. # # @attr [Symbol] method the HTTP method (i.e. :head, :options, :get, :post, :put, :patch or :delete) # @attr [String] uri the request URI # @attr [String, nil] body the request body # @attr [Hash{String => Array}] headers the request headers class Request < Struct.new(:method, :uri, :body, :headers) include Normalizers::Header include Normalizers::Body def initialize(*args) skip_port_stripping = false if args.last == :skip_port_stripping skip_port_stripping = true args.pop end super(*args) self.method = self.method.to_s.downcase.to_sym if self.method self.uri = without_standard_port(self.uri) unless skip_port_stripping end # Builds a serializable hash from the request data. # # @return [Hash] hash that represents this request and can be easily # serialized. # @see Request.from_hash def to_hash { 'method' => method.to_s, 'uri' => uri, 'body' => serializable_body, 'headers' => headers } end # Constructs a new instance from a hash. # # @param [Hash] hash the hash to use to construct the instance. # @return [Request] the request def self.from_hash(hash) method = hash['method'] method &&= method.to_sym new method, hash['uri'], body_from(hash['body']), hash['headers'], :skip_port_stripping end # Parses the URI using the configured `uri_parser`. # # @return [#schema, #host, #port, #path, #query] A parsed URI object. def parsed_uri VCR.configuration.uri_parser.parse(uri) end @@object_method = Object.instance_method(:method) def method(*args) return super if args.empty? @@object_method.bind(self).call(*args) end # Decorates a {Request} with its current type. class Typed < DelegateClass(self) # @return [Symbol] One of `:ignored`, `:stubbed`, `:recordable` or `:unhandled`. attr_reader :type # @param [Request] request the request # @param [Symbol] type the type. Should be one of `:ignored`, `:stubbed`, `:recordable` or `:unhandled`. def initialize(request, type) @type = type super(request) end # @return [Boolean] whether or not this request is being ignored def ignored? type == :ignored end # @return [Boolean] whether or not this request is being stubbed by VCR # @see #externally_stubbed? # @see #stubbed? def stubbed_by_vcr? type == :stubbed_by_vcr end # @return [Boolean] whether or not this request is being stubbed by an # external library (such as WebMock). # @see #stubbed_by_vcr? # @see #stubbed? def externally_stubbed? type == :externally_stubbed end # @return [Boolean] whether or not this request will be recorded. def recordable? type == :recordable end # @return [Boolean] whether or not VCR knows how to handle this request. def unhandled? type == :unhandled end # @return [Boolean] whether or not this request will be made for real. # @note VCR allows `:ignored` and `:recordable` requests to be made for real. def real? ignored? || recordable? end # @return [Boolean] whether or not this request will be stubbed. # It may be stubbed by an external library or by VCR. # @see #stubbed_by_vcr? # @see #externally_stubbed? def stubbed? stubbed_by_vcr? || externally_stubbed? end undef method end # Provides fiber-awareness for the {VCR::Configuration#around_http_request} hook. class FiberAware < DelegateClass(Typed) # Yields the fiber so the request can proceed. # # @return [VCR::Response] the response from the request def proceed Fiber.yield end # Builds a proc that allows the request to proceed when called. # This allows you to treat the request as a proc and pass it on # to a method that yields (at which point the request will proceed). # # @return [Proc] the proc def to_proc lambda { proceed } end undef method end private def without_standard_port(uri) return uri if uri.nil? u = parsed_uri return uri unless [['http', 80], ['https', 443]].include?([u.scheme, u.port]) u.port = nil u.to_s end end # The response of an {HTTPInteraction}. # # @attr [ResponseStatus] status the status of the response # @attr [Hash{String => Array}] headers the response headers # @attr [String] body the response body # @attr [nil, String] http_version the HTTP version # @attr [Hash] adapter_metadata Additional metadata used by a specific VCR adapter. class Response < Struct.new(:status, :headers, :body, :http_version, :adapter_metadata) include Normalizers::Header include Normalizers::Body def initialize(*args) super(*args) self.adapter_metadata ||= {} end # Builds a serializable hash from the response data. # # @return [Hash] hash that represents this response # and can be easily serialized. # @see Response.from_hash def to_hash { 'status' => status.to_hash, 'headers' => headers, 'body' => serializable_body, 'http_version' => http_version }.tap do |hash| hash['adapter_metadata'] = adapter_metadata unless adapter_metadata.empty? end end # Constructs a new instance from a hash. # # @param [Hash] hash the hash to use to construct the instance. # @return [Response] the response def self.from_hash(hash) new ResponseStatus.from_hash(hash.fetch('status', {})), hash['headers'], body_from(hash['body']), hash['http_version'], hash['adapter_metadata'] end # Updates the Content-Length response header so that it is # accurate for the response body. def update_content_length_header edit_header('Content-Length') { body ? body.bytesize.to_s : '0' } end # The type of encoding. # # @return [String] encoding type def content_encoding enc = get_header('Content-Encoding') and enc.first end # Checks if the type of encoding is one of "gzip" or "deflate". def compressed? %w[ gzip deflate ].include? content_encoding end # Checks if VCR decompressed the response body def vcr_decompressed? adapter_metadata['vcr_decompressed'] end # Decodes the compressed body and deletes evidence that it was ever compressed. # # @return self # @raise [VCR::Errors::UnknownContentEncodingError] if the content encoding # is not a known encoding. def decompress self.class.decompress(body, content_encoding) { |new_body| self.body = new_body update_content_length_header adapter_metadata['vcr_decompressed'] = content_encoding delete_header('Content-Encoding') } return self end # Recompresses the decompressed body according to adapter metadata. # # @raise [VCR::Errors::UnknownContentEncodingError] if the content encoding # stored in the adapter metadata is unknown def recompress type = adapter_metadata['vcr_decompressed'] new_body = begin case type when 'gzip' body_str = '' args = [StringIO.new(body_str)] args << { :encoding => 'ASCII-8BIT' } if ''.respond_to?(:encoding) writer = Zlib::GzipWriter.new(*args) writer.write(body) writer.close body_str when 'deflate' Zlib::Deflate.inflate(body) when 'identity', NilClass nil else raise Errors::UnknownContentEncodingError, "unknown content encoding: #{type}" end end if new_body self.body = new_body update_content_length_header headers['Content-Encoding'] = type end end begin require 'zlib' require 'stringio' HAVE_ZLIB = true rescue LoadError HAVE_ZLIB = false end # Decode string compressed with gzip or deflate # # @raise [VCR::Errors::UnknownContentEncodingError] if the content encoding # is not a known encoding. def self.decompress(body, type) unless HAVE_ZLIB warn "VCR: cannot decompress response; Zlib not available" return end case type when 'gzip' args = [StringIO.new(body)] args << { :encoding => 'ASCII-8BIT' } if ''.respond_to?(:encoding) yield Zlib::GzipReader.new(*args).read when 'deflate' yield Zlib::Inflate.inflate(body) when 'identity', NilClass return else raise Errors::UnknownContentEncodingError, "unknown content encoding: #{type}" end end end # The response status of an {HTTPInteraction}. # # @attr [Integer] code the HTTP status code # @attr [String] message the HTTP status message (e.g. "OK" for a status of 200) class ResponseStatus < Struct.new(:code, :message) # Builds a serializable hash from the response status data. # # @return [Hash] hash that represents this response status # and can be easily serialized. # @see ResponseStatus.from_hash def to_hash { 'code' => code, 'message' => message } end # Constructs a new instance from a hash. # # @param [Hash] hash the hash to use to construct the instance. # @return [ResponseStatus] the response status def self.from_hash(hash) new hash['code'], hash['message'] end end # Represents a single interaction over HTTP, containing a request and a response. # # @attr [Request] request the request # @attr [Response] response the response # @attr [Time] recorded_at when this HTTP interaction was recorded class HTTPInteraction < Struct.new(:request, :response, :recorded_at) def initialize(*args) super self.recorded_at ||= Time.now end # Builds a serializable hash from the HTTP interaction data. # # @return [Hash] hash that represents this HTTP interaction # and can be easily serialized. # @see HTTPInteraction.from_hash def to_hash { 'request' => request.to_hash, 'response' => response.to_hash, 'recorded_at' => recorded_at.httpdate } end # Constructs a new instance from a hash. # # @param [Hash] hash the hash to use to construct the instance. # @return [HTTPInteraction] the HTTP interaction def self.from_hash(hash) new Request.from_hash(hash.fetch('request', {})), Response.from_hash(hash.fetch('response', {})), Time.httpdate(hash.fetch('recorded_at')) end # @return [HookAware] an instance with additional capabilities # suitable for use in `before_record` and `before_playback` hooks. def hook_aware HookAware.new(self) end # Decorates an {HTTPInteraction} with additional methods useful # for a `before_record` or `before_playback` hook. class HookAware < DelegateClass(HTTPInteraction) def initialize(http_interaction) @ignored = false super end # Flags the HTTP interaction so that VCR ignores it. This is useful in # a {VCR::Configuration#before_record} or {VCR::Configuration#before_playback} # hook so that VCR does not record or play it back. # @see #ignored? def ignore! @ignored = true end # @return [Boolean] whether or not this HTTP interaction should be ignored. # @see #ignore! def ignored? !!@ignored end # Replaces a string in any part of the HTTP interaction (headers, request body, # response body, etc) with the given replacement text. # # @param [#to_s] text the text to replace # @param [#to_s] replacement_text the text to put in its place def filter!(text, replacement_text) text, replacement_text = text.to_s, replacement_text.to_s return self if [text, replacement_text].any? { |t| t.empty? } filter_object!(self, text, replacement_text) end private def filter_object!(object, text, replacement_text) if object.respond_to?(:gsub) object.gsub!(text, replacement_text) if object.include?(text) elsif Hash === object filter_hash!(object, text, replacement_text) elsif object.respond_to?(:each) # This handles nested arrays and structs object.each { |o| filter_object!(o, text, replacement_text) } end object end def filter_hash!(hash, text, replacement_text) filter_object!(hash.values, text, replacement_text) hash.keys.each do |k| new_key = filter_object!(k.dup, text, replacement_text) hash[new_key] = hash.delete(k) unless k == new_key end end end end end