Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion lib/gcr.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
117 changes: 77 additions & 40 deletions lib/gcr/cassette.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ class GCR::Cassette
VERSION = 2

attr_reader :reqs
attr_accessor :reqs_calls_counts

# Delete all recorded cassettes.
#
Expand All @@ -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?
Expand All @@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 8 additions & 4 deletions lib/gcr/request.rb
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
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),
)
end

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"],
)
Expand All @@ -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
Expand All @@ -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
Expand Down
21 changes: 18 additions & 3 deletions lib/gcr/response.rb
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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