diff --git a/lib/gcr.rb b/lib/gcr.rb index bb1f1b4..a83e25d 100644 --- a/lib/gcr.rb +++ b/lib/gcr.rb @@ -39,6 +39,18 @@ def cassette_dir @cassette_dir || (raise ConfigError, "no cassette dir configured") end + # Specify if cassettes should be compressed to zz + def compress=(boolean) + @compress = boolean + end + + # Whether cassettes should be compressed to zz + # + # Returns a boolean + def compress? + @compress ||= false + end + # Specify the stub to intercept calls to. # # stub - A GRPC::ClientStub instance. @@ -84,7 +96,7 @@ def cassette # Returns nothing. def with_cassette(name, &blk) @cassette = Cassette.new(name) - if @cassette.exist? + if @cassette.exist? && ENV['GCR_RECORD'].nil? @cassette.play(&blk) else @cassette.record(&blk) diff --git a/lib/gcr/cassette.rb b/lib/gcr/cassette.rb index d34e6c6..b403a2c 100644 --- a/lib/gcr/cassette.rb +++ b/lib/gcr/cassette.rb @@ -2,6 +2,7 @@ class GCR::Cassette VERSION = 2 attr_reader :reqs + attr_accessor :reqs_calls_counts # Delete all recorded cassettes. # @@ -18,8 +19,9 @@ def self.delete_all # # Returns nothing. def initialize(name) - @path = File.join(GCR.cassette_dir, "#{name}.json") + @path = File.join(GCR.cassette_dir, "#{name}.json#{".zz" if GCR.compress?}") @reqs = [] + @reqs_calls_counts = {} end # Does this cassette exist? @@ -33,7 +35,8 @@ def exist? # # Returns nothing. def load - data = JSON.parse(File.read(@path)) + json_data = @path.ends_with?(".zz") ? Zlib::Inflate.inflate(File.read(@path)) : File.read(@path) + data = JSON.parse(json_data) if data["version"] != VERSION raise "GCR cassette version #{data["version"]} not supported" @@ -48,11 +51,14 @@ def load # # Returns nothing. def save - File.open(@path, "w") do |f| - f.write(JSON.pretty_generate( - "version" => VERSION, - "reqs" => reqs, - )) + json_content = JSON.pretty_generate( + "version" => VERSION, + "reqs" => reqs + ) + if GCR.compress? + File.write(@path, Zlib::Deflate.deflate(json_content), encoding: "ascii-8bit") + else + File.write(@path, json_content) end end @@ -81,32 +87,39 @@ def start_recording alias_method :orig_request_response, :request_response def request_response(*args, return_op: false, **kwargs) + req = GCR::Request.from_proto(*args) if return_op - # capture the operation - operation = orig_request_response(*args, return_op: return_op, **kwargs) - - # capture the response - resp = orig_request_response(*args, return_op: false, **kwargs) - - req = GCR::Request.from_proto(*args) - if GCR.cassette.reqs.none? { |r, _| r == req } - GCR.cassette.reqs << [req, GCR::Response.from_proto(resp)] + # captures the operation + operation = orig_request_response(*args, return_op: true, **kwargs) + + stub = self + operation.define_singleton_method(:execute) do + # performs the operation (actual API call) and captures the response + begin + resp = stub.orig_request_response(*args, return_op: false, **kwargs) + GCR.cassette.save_interaction(req, resp) + resp + rescue => resp + GCR.cassette.save_interaction(req, resp) + raise resp + end end # then return it operation else - orig_request_response(*args, return_op: return_op, **kwargs).tap do |resp| - req = GCR::Request.from_proto(*args) - if GCR.cassette.reqs.none? { |r, _| r == req } - GCR.cassette.reqs << [req, GCR::Response.from_proto(resp)] - end - end + resp = orig_request_response(*args, return_op: return_op, **kwargs) + GCR.cassette.save_interaction(req, resp) + resp end end end end + def save_interaction(req, resp) + GCR.cassette.reqs << [req, GCR::Response.from_proto(resp)] + end + def stop_recording GCR.stub.class.class_eval do alias_method :request_response, :orig_request_response @@ -122,30 +135,54 @@ def start_playing def request_response(*args, return_op: false, **kwargs) req = GCR::Request.from_proto(*args) - GCR.cassette.reqs.each do |other_req, resp| - if req == other_req - - # check if our request wants an operation returned rather than the response - if return_op - # if so, collect the original operation - operation = orig_request_response(*args, return_op: return_op, **kwargs) - - # hack the execute method to return the response we recorded - operation.define_singleton_method(:execute) { return resp.to_proto } - - # then return it - return operation - else - # otherwise just return the response - return resp.to_proto - end + + # check if our request wants an operation returned rather than the response + if return_op + # if so, collect the original operation + operation = orig_request_response(*args, return_op: return_op, **kwargs) + + # hack the execute method to return the response we recorded + operation.define_singleton_method(:execute) do + GCR.cassette.read_recorded_response(req).to_proto end + + # then return it + operation + else + # otherwise just return the response + GCR.cassette.read_recorded_response(req).to_proto end - raise GCR::NoRecording end end end + def read_recorded_response(req) + interactions = reqs.select { |persisted_req, _| req == persisted_req } + resp = interactions[calls_count(req)]&.last + iterate_calls_count(req) + if resp.nil? + raise_error(req, interactions: interactions) + end + + resp + end + + def calls_count(req) + reqs_calls_counts[req.to_h] ||= 0 + end + + def iterate_calls_count(req) + reqs_calls_counts[req.to_h] += 1 + end + + def raise_error(req, interactions:) + calls_count = calls_count(req) + raise GCR::NoRecording.new(["Unrecorded request :", + "called #{calls_count} #{(calls_count > 1) ? "times" : "time"}, (recorded #{interactions.size})", + req.class_name, + req.body].join("\n")) + end + def stop_playing GCR.stub.class.class_eval do alias_method :request_response, :orig_request_response diff --git a/lib/gcr/request.rb b/lib/gcr/request.rb index 4679d8f..e17a4cb 100644 --- a/lib/gcr/request.rb +++ b/lib/gcr/request.rb @@ -1,7 +1,7 @@ class GCR::Request def self.from_proto(route, proto_req, *_) new( - "route" => route, + "route" => route, "class_name" => proto_req.class.name, "body" => proto_req.to_json(emit_defaults: true), ) @@ -9,7 +9,7 @@ def self.from_proto(route, proto_req, *_) def self.from_hash(hash_req) new( - "route" => hash_req["route"], + "route" => hash_req["route"], "class_name" => hash_req["class_name"], "body" => hash_req["body"], ) @@ -18,9 +18,9 @@ def self.from_hash(hash_req) attr_reader :route, :class_name, :body def initialize(opts) - @route = opts["route"] + @route = opts["route"] @class_name = opts["class_name"] - @body = opts["body"] + @body = opts["body"] end def parsed_body @@ -31,6 +31,10 @@ def to_json(*_) JSON.dump("route" => route, "class_name" => class_name, "body" => body) end + def to_h + {"route" => route, "class_name" => class_name, "body" => body} + end + def to_proto [route, Object.const_get(class_name).decode_json(body)] end diff --git a/lib/gcr/response.rb b/lib/gcr/response.rb index d71d9a4..6624c20 100644 --- a/lib/gcr/response.rb +++ b/lib/gcr/response.rb @@ -1,8 +1,18 @@ class GCR::Response + GOOGLE_ADS_ERROR_CLASS = 'Google::Ads::GoogleAds::Errors::GoogleAdsError'.freeze + def self.from_proto(proto_resp) + class_name = proto_resp.class.name + + body = if class_name == GOOGLE_ADS_ERROR_CLASS + proto_resp.failure.to_json(emit_defaults: true) + else + proto_resp.to_json(emit_defaults: true) + end + new( - "class_name" => proto_resp.class.name, - "body" => proto_resp.to_json(emit_defaults: true) + "class_name" => class_name, + "body" => body ) end @@ -29,6 +39,11 @@ def to_json(*_) end def to_proto - Object.const_get(class_name).decode_json(body) + if class_name == GOOGLE_ADS_ERROR_CLASS + failure = Google::Ads::GoogleAds.const_get(GoogleApi::VERSION)::Errors::GoogleAdsFailure.decode_json(body) + raise Google::Ads::GoogleAds::Errors::GoogleAdsError.new(failure) + else + Object.const_get(class_name).decode_json(body) + end end end