Skip to content

Commit 117148d

Browse files
authored
feat(gapic-common): Compatibility with protobuf v23 generated map fields (#948)
1 parent e4984a4 commit 117148d

File tree

5 files changed

+112
-67
lines changed

5 files changed

+112
-67
lines changed

gapic-common/lib/gapic/protobuf.rb

Lines changed: 39 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616

1717
module Gapic
1818
##
19-
# TODO: Describe Protobuf
19+
# A set of internal utilities for coercing data to protobuf messages.
20+
#
2021
module Protobuf
2122
##
2223
# Creates an instance of a protobuf message from a hash that may include nested hashes. `google/protobuf` allows
@@ -31,10 +32,13 @@ module Protobuf
3132
def self.coerce hash, to:
3233
return hash if hash.is_a? to
3334

35+
# Special case handling of certain types
36+
return time_to_timestamp hash if to == Google::Protobuf::Timestamp && hash.is_a?(Time)
37+
3438
# Sanity check: input must be a Hash
3539
raise ArgumentError, "Value #{hash} must be a Hash or a #{to.name}" unless hash.is_a? Hash
3640

37-
hash = coerce_submessages hash, to
41+
hash = coerce_submessages hash, to.descriptor
3842
to.new hash
3943
end
4044

@@ -44,89 +48,58 @@ def self.coerce hash, to:
4448
# @private
4549
#
4650
# @param hash [Hash] The hash whose nested hashes will be coerced.
47-
# @param message_class [Class] The corresponding protobuf message class of the given hash.
51+
# @param message_descriptor [Google::Protobuf::Descriptor] The protobuf descriptor for the message.
4852
#
4953
# @return [Hash] A hash whose nested hashes have been coerced.
50-
def self.coerce_submessages hash, message_class
54+
def self.coerce_submessages hash, message_descriptor
5155
return nil if hash.nil?
5256
coerced = {}
53-
message_descriptor = message_class.descriptor
5457
hash.each do |key, val|
5558
field_descriptor = message_descriptor.lookup key.to_s
56-
coerced[key] = if field_descriptor && field_descriptor.type == :message
57-
coerce_submessage val, field_descriptor
58-
elsif field_descriptor && field_descriptor.type == :bytes &&
59-
(val.is_a?(IO) || val.is_a?(StringIO))
60-
val.binmode.read
61-
else
62-
# `google/protobuf` should throw an error if no field descriptor is
63-
# found. Simply pass through.
64-
val
65-
end
59+
coerced[key] =
60+
if field_descriptor&.type == :message
61+
coerce_submessage val, field_descriptor
62+
elsif field_descriptor&.type == :bytes && (val.is_a?(IO) || val.is_a?(StringIO))
63+
val.binmode.read
64+
else
65+
# For non-message fields, just pass the scalar value through.
66+
# Note: if field_descriptor is not found, we just pass the value
67+
# through and let protobuf raise an error.
68+
val
69+
end
6670
end
6771
coerced
6872
end
6973

7074
##
71-
# Coerces the value of a field to be acceptable by the instantiation method of the wrapping message.
75+
# Coerces a message-typed field.
76+
# The field can be a normal single message, a repeated message, or a map.
7277
#
7378
# @private
7479
#
75-
# @param val [Object] The value to be coerced.
76-
# @param field_descriptor [Google::Protobuf::FieldDescriptor] The field descriptor of the value.
80+
# @param val [Object] The value to coerce
81+
# @param field_descriptor [Google::Protobuf::FieldDescriptor] The field descriptor.
7782
#
78-
# @return [Object] The coerced version of the given value.
7983
def self.coerce_submessage val, field_descriptor
80-
if (field_descriptor.label == :repeated) && !(map_field? field_descriptor)
81-
coerce_array val, field_descriptor
82-
elsif field_descriptor.subtype.msgclass == Google::Protobuf::Timestamp && val.is_a?(Time)
83-
time_to_timestamp val
84+
if val.is_a? Array
85+
# Assume this is a repeated message field, iterate over it and coerce
86+
# each to the message class.
87+
# Protobuf will raise an error if this assumption is incorrect.
88+
val.map do |elem|
89+
coerce elem, to: field_descriptor.subtype.msgclass
90+
end
91+
elsif field_descriptor.label == :repeated
92+
# Non-array passed to a repeated field: assume this is a map, and that
93+
# a hash is being passed, and let protobuf handle the conversion.
94+
# Protobuf will raise an error if this assumption is incorrect.
95+
val
8496
else
85-
coerce_value val, field_descriptor
86-
end
87-
end
88-
89-
##
90-
# Coerces the values of an array to be acceptable by the instantiation method the wrapping message.
91-
#
92-
# @private
93-
#
94-
# @param array [Array<Object>] The values to be coerced.
95-
# @param field_descriptor [Google::Protobuf::FieldDescriptor] The field descriptor of the values.
96-
#
97-
# @return [Array<Object>] The coerced version of the given values.
98-
def self.coerce_array array, field_descriptor
99-
raise ArgumentError, "Value #{array} must be an array" unless array.is_a? Array
100-
array.map do |val|
101-
coerce_value val, field_descriptor
97+
# Assume this is a normal single message, and coerce to the message
98+
# class.
99+
coerce val, to: field_descriptor.subtype.msgclass
102100
end
103101
end
104102

105-
##
106-
# Hack to determine if field_descriptor is for a map.
107-
#
108-
# TODO(geigerj): Remove this once protobuf Ruby supports an official way
109-
# to determine if a FieldDescriptor represents a map.
110-
# See: https://github.com/google/protobuf/issues/3425
111-
def self.map_field? field_descriptor
112-
(field_descriptor.label == :repeated) &&
113-
(field_descriptor.subtype.name.include? "_MapEntry_")
114-
end
115-
116-
##
117-
# Coerces the value of a field to be acceptable by the instantiation method of the wrapping message.
118-
#
119-
# @private
120-
#
121-
# @param val [Object] The value to be coerced.
122-
# @param field_descriptor [Google::Protobuf::FieldDescriptor] The field descriptor of the value.
123-
#
124-
# @return [Object] The coerced version of the given value.
125-
def self.coerce_value val, field_descriptor
126-
return val unless (val.is_a? Hash) && !(map_field? field_descriptor)
127-
coerce val, to: field_descriptor.subtype.msgclass
128-
end
129-
130103
##
131104
# Utility for converting a Google::Protobuf::Timestamp instance to a Ruby time.
132105
#
@@ -147,6 +120,6 @@ def self.time_to_timestamp time
147120
Google::Protobuf::Timestamp.new seconds: time.to_i, nanos: time.nsec
148121
end
149122

150-
private_class_method :coerce_submessages, :coerce_submessage, :coerce_array, :coerce_value, :map_field?
123+
private_class_method :coerce_submessages, :coerce_submessage
151124
end
152125
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// Copyright 2023 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
syntax = "proto3";
16+
17+
package gapic.examples;
18+
19+
message MapTest {
20+
string name = 1;
21+
map<string, string> map_field = 4;
22+
}

gapic-common/test/fixtures/fixture2_pb.rb

Lines changed: 38 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gapic-common/test/gapic/protobuf/coerce_test.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ class ProtobufCoerceTest < Minitest::Spec
7575
end
7676
end
7777

78-
it "handles maps" do
78+
it "handles maps using the DSL" do
7979
request_hash = { name: USER_NAME, map_field: MAP }
8080
user = Gapic::Protobuf.coerce request_hash, to: Gapic::Examples::User
8181
_(user).must_be_kind_of Gapic::Examples::User
@@ -86,6 +86,17 @@ class ProtobufCoerceTest < Minitest::Spec
8686
end
8787
end
8888

89+
it "handles maps using raw descriptor strings" do
90+
request_hash = { name: USER_NAME, map_field: MAP }
91+
obj = Gapic::Protobuf.coerce request_hash, to: Gapic::Examples::MapTest
92+
_(obj).must_be_kind_of Gapic::Examples::MapTest
93+
_(obj.name).must_equal USER_NAME
94+
_(obj.map_field).must_be_kind_of Google::Protobuf::Map
95+
obj.map_field.each do |k, v|
96+
_(MAP[k]).must_equal v
97+
end
98+
end
99+
89100
it "handles IO instances" do
90101
file = File.new "test/fixtures/fixture_file.txt"
91102
request_hash = { bytes_field: file }

gapic-common/test/test_helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
require "gapic/rest"
2323
require "google/protobuf/any_pb"
2424
require_relative "./fixtures/fixture_pb"
25+
require_relative "./fixtures/fixture2_pb"
2526
require_relative "./fixtures/transcoding_example_pb"
2627

2728
class FakeFaradayError < ::Faraday::Error

0 commit comments

Comments
 (0)