From 932ac0e58d58ffd4821bd945e8f7c4dc67f3d3cc Mon Sep 17 00:00:00 2001 From: tdutreui-solocal Date: Thu, 5 Sep 2024 12:00:55 +0200 Subject: [PATCH 1/6] GRPC for Google write operations + compression --- lib/gcr.rb | 12 ++++++++++++ lib/gcr/cassette.rb | 31 +++++++++++++++++++------------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/lib/gcr.rb b/lib/gcr.rb index bb1f1b4..c4b7e34 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. diff --git a/lib/gcr/cassette.rb b/lib/gcr/cassette.rb index d34e6c6..4b25157 100644 --- a/lib/gcr/cassette.rb +++ b/lib/gcr/cassette.rb @@ -18,7 +18,7 @@ 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 = [] end @@ -33,7 +33,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 +49,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 @@ -82,12 +86,15 @@ def start_recording def request_response(*args, return_op: false, **kwargs) if return_op - # capture the operation - operation = orig_request_response(*args, return_op: return_op, **kwargs) + # captures the operation + operation = orig_request_response(*args, return_op: true, **kwargs) - # capture the response + # performs the operation (actual API call) and captures the response resp = orig_request_response(*args, return_op: false, **kwargs) + # prevents duplicate queries + operation.define_singleton_method(:execute) { resp } + req = GCR::Request.from_proto(*args) if GCR.cassette.reqs.none? { |r, _| r == req } GCR.cassette.reqs << [req, GCR::Response.from_proto(resp)] @@ -131,7 +138,7 @@ def request_response(*args, return_op: false, **kwargs) 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 } + operation.define_singleton_method(:execute) { resp.to_proto } # then return it return operation @@ -141,7 +148,7 @@ def request_response(*args, return_op: false, **kwargs) end end end - raise GCR::NoRecording + raise GCR::NoRecording.new(["Unrecorded request :", req.class_name, req.body].join("\n")) end end end From b0da7cac8b52e9cdceec1085c242998a66f79d22 Mon Sep 17 00:00:00 2001 From: tdutreui-solocal Date: Wed, 11 Sep 2024 10:26:26 +0200 Subject: [PATCH 2/6] GRPC for Google write operations + compression - naming polish --- lib/gcr.rb | 2 +- lib/gcr/cassette.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/gcr.rb b/lib/gcr.rb index c4b7e34..dc9f411 100644 --- a/lib/gcr.rb +++ b/lib/gcr.rb @@ -47,7 +47,7 @@ def compress=(boolean) # Whether cassettes should be compressed to zz # # Returns a boolean - def compress + def compress? @compress ||= false end diff --git a/lib/gcr/cassette.rb b/lib/gcr/cassette.rb index 4b25157..5ae6dfb 100644 --- a/lib/gcr/cassette.rb +++ b/lib/gcr/cassette.rb @@ -18,7 +18,7 @@ def self.delete_all # # Returns nothing. def initialize(name) - @path = File.join(GCR.cassette_dir, "#{name}.json#{".zz" if GCR.compress}") + @path = File.join(GCR.cassette_dir, "#{name}.json#{".zz" if GCR.compress?}") @reqs = [] end @@ -53,7 +53,7 @@ def save "version" => VERSION, "reqs" => reqs ) - if GCR.compress + if GCR.compress? File.write(@path, Zlib::Deflate.deflate(json_content), encoding: "ascii-8bit") else File.write(@path, json_content) From e6b5bca9bc26d909ef13114553712fc14688b02e Mon Sep 17 00:00:00 2001 From: tdutreui-solocal Date: Wed, 16 Oct 2024 12:38:53 +0200 Subject: [PATCH 3/6] GRPC for Google write operations + compression - allow recording same API calls with different responses --- lib/gcr/cassette.rb | 92 +++++++++++++++++++++++++++++---------------- lib/gcr/request.rb | 12 ++++-- 2 files changed, 67 insertions(+), 37 deletions(-) diff --git a/lib/gcr/cassette.rb b/lib/gcr/cassette.rb index 5ae6dfb..ad18801 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. # @@ -20,6 +21,7 @@ def self.delete_all def initialize(name) @path = File.join(GCR.cassette_dir, "#{name}.json#{".zz" if GCR.compress?}") @reqs = [] + @reqs_calls_counts = {} end # Does this cassette exist? @@ -85,35 +87,34 @@ 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 # captures the operation operation = orig_request_response(*args, return_op: true, **kwargs) - # performs the operation (actual API call) and captures the response - resp = orig_request_response(*args, return_op: false, **kwargs) - - # prevents duplicate queries - operation.define_singleton_method(:execute) { resp } - - req = GCR::Request.from_proto(*args) - if GCR.cassette.reqs.none? { |r, _| r == req } - GCR.cassette.reqs << [req, GCR::Response.from_proto(resp)] + stub = self + operation.define_singleton_method(:execute) do + # performs the operation (actual API call) and captures the response + resp = stub.orig_request_response(*args, return_op: false, **kwargs) + GCR.cassette.save_interaction(req, resp) + resp 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 @@ -129,30 +130,55 @@ 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) { 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.new(["Unrecorded request :", req.class_name, req.body].join("\n")) 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 From 7bc84ed6404dbfdf6131d3b03ea28ee72d0b49d6 Mon Sep 17 00:00:00 2001 From: tdutreui-solocal Date: Thu, 20 Mar 2025 12:16:39 +0100 Subject: [PATCH 4/6] add explicit recording envvar --- lib/gcr.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gcr.rb b/lib/gcr.rb index dc9f411..a83e25d 100644 --- a/lib/gcr.rb +++ b/lib/gcr.rb @@ -96,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) From 5665b72f7015dc6797cc603e4e2f60d9eb327c7b Mon Sep 17 00:00:00 2001 From: tdutreui-solocal Date: Mon, 25 Aug 2025 11:42:19 +0200 Subject: [PATCH 5/6] better spacing --- lib/gcr/cassette.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/gcr/cassette.rb b/lib/gcr/cassette.rb index ad18801..f2fd0d2 100644 --- a/lib/gcr/cassette.rb +++ b/lib/gcr/cassette.rb @@ -175,8 +175,7 @@ def raise_error(req, interactions:) raise GCR::NoRecording.new(["Unrecorded request :", "called #{calls_count} #{(calls_count > 1) ? "times" : "time"}, (recorded #{interactions.size})", req.class_name, - req.body] - .join("\n")) + req.body].join("\n")) end def stop_playing From 3512f086e61b1bf278a3a05278833b508458bae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Grais=20Fr=C3=A9d=C3=A9ric?= Date: Mon, 5 Jan 2026 09:53:04 +0100 Subject: [PATCH 6/6] Supporting recording of Google Errors --- lib/gcr/cassette.rb | 11 ++++++++--- lib/gcr/response.rb | 21 ++++++++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/gcr/cassette.rb b/lib/gcr/cassette.rb index f2fd0d2..b403a2c 100644 --- a/lib/gcr/cassette.rb +++ b/lib/gcr/cassette.rb @@ -95,9 +95,14 @@ def request_response(*args, return_op: false, **kwargs) stub = self operation.define_singleton_method(:execute) do # performs the operation (actual API call) and captures the response - resp = stub.orig_request_response(*args, return_op: false, **kwargs) - GCR.cassette.save_interaction(req, resp) - resp + 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 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