File: grpc_transcoder.rb

package info (click to toggle)
ruby-gapic-common 1.3.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 392 kB
  • sloc: ruby: 2,081; makefile: 4
file content (272 lines) | stat: -rw-r--r-- 12,272 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

require "gapic/rest/grpc_transcoder/http_binding"

module Gapic
  module Rest
    # @private
    # Transcodes a proto request message into HTTP Rest call components
    # using a configuration of bindings.
    # Internal doc go/actools-regapic-grpc-transcoding.
    class GrpcTranscoder
      def initialize bindings = nil
        @bindings = bindings || []
      end

      ##
      # @private
      # Creates a new trascoder that is a copy of this one, but with an additional
      # binding defined by the parameters.
      #
      # @param uri_method [Symbol] The rest verb for the binding.
      # @param uri_template [String] The string with uri template for the binding.
      #   This string will be expanded with the parameters from variable bindings.
      # @param matches [Array<Array>] Variable bindings in an array. Every element
      #   of the array is an [Array] triplet, where:
      #   - the first element is a [String] field path (e.g. `foo.bar`) in the request
      #     to bind to
      #   - the second element is a [Regexp] to match the field value
      #   - the third element is a [Boolean] whether the slashes in the field value
      #     should be preserved (as opposed to escaped) when expanding the uri template.
      # @param body [String, Nil] The body template, e.g. `*` or a field path.
      #
      # @return [Gapic::Rest::GrpcTranscoder] The updated transcoder.
      def with_bindings uri_method:, uri_template:, matches: [], body: nil
        binding = HttpBinding.create_with_validation(uri_method: uri_method,
                                                     uri_template: uri_template,
                                                     matches: matches,
                                                     body: body)
        GrpcTranscoder.new @bindings + [binding]
      end

      ##
      # @private
      # Performs the full grpc transcoding -- creating a REST request from the GRPC request
      # by matching the http bindings and choosing the last one to match.
      # From the matching binding and the request the following components of the REST request
      # are produced:
      # - A [Symbol] representing the Rest verb (e.g. `:get`)
      # - Uri [String] (e.g. `books/100:read`)
      # - Query string params in the form of key-value pairs [Array<Array{String, String}>]
      #   (e.g. [["foo", "bar"], ["baz", "qux"]])
      # - Body of the request [String]
      #
      # @param request [Object] The GRPC request object
      #
      # @return [Array] The components of the transcoded request.
      def transcode request
        # Using bindings in reverse here because of the "last one wins" rule
        @bindings.reverse.each do |http_binding|
          # The main reason we are using request.to_json here
          # is that the unset proto3_optional fields will not be
          # in that JSON, letting us skip the checks that would look like
          #   `request.respond_to?("has_#{key}?".to_sym) && !request.send("has_#{key}?".to_sym)`
          # The reason we set emit_defaults: true is to avoid
          # having to figure out default values for the required
          # fields at a runtime.
          #
          # Make a new one for each binding because extract_scalar_value! is destructive
          request_hash = JSON.parse request.to_json emit_defaults: true

          uri_values = bind_uri_values! http_binding, request_hash
          next if uri_values.any? { |_, value| value.nil? }

          # Note that the body template can only point to a top-level field,
          # so there is no need to split the path.
          next if http_binding.body && http_binding.body != "*" && !(request.respond_to? http_binding.body.to_sym)

          method = http_binding.method
          uri = expand_template http_binding.template, uri_values
          body, query_params = construct_body_query_params http_binding.body, request_hash, request

          return method, uri, query_params, body
        end

        raise ::Gapic::Common::Error,
              "Request object does not match any transcoding template. Cannot form a correct REST call."
      end

      private

      # Binds request values for the uri template expansion.
      # This method modifies the provided `request_hash` parameter.
      # Returned values are percent-escaped with slashes potentially preserved.
      # @param http_binding [Gapic::Rest::GrpcTranscoder::HttpBinding]
      #   Http binding to get the field bindings from.
      # @param request_hash [Hash]
      #   A hash of the GRPC request with the unset proto3_optional fields pre-removed.
      #   !!! This hash will be modified. The bound fields will be deleted. !!!
      # @return [Hash{String, String}]
      #   Name to value hash of the variables for the uri template expansion.
      #   The values are percent-escaped with slashes potentially preserved.
      def bind_uri_values! http_binding, request_hash
        http_binding.field_bindings.to_h do |field_binding|
          field_path_camel = field_binding.field_path.split(".").map { |part| camel_name_for part }.join(".")
          field_value = extract_scalar_value! request_hash, field_path_camel, field_binding.regex

          if field_value
            field_value = field_value.split("/").map { |segment| percent_escape segment }.join("/")
          end

          [field_binding.field_path, field_value]
        end
      end

      # Percent-escapes a string.
      # @param str [String] String to escape.
      # @return [String] Escaped string.
      def percent_escape str
        # `+` to represent spaces is not currently supported in Showcase server.
        CGI.escape(str).gsub("+", "%20")
      end

      # Constructs body and query parameters for the Rest request.
      # @param body_template [String, Nil] The template for the body, e.g. `*`.
      # @param request_hash_without_uri [Hash]
      #   The hash of the GRPC request with the unset proto3_optional fields
      #   and the values that are bound to URI removed.
      # @param request [Object] The GRPC request.
      # @return [Array{String, Array}] A pair of body and query parameters.
      def construct_body_query_params body_template, request_hash_without_uri, request
        body = ""
        query_params = []

        if body_template == "*"
          body = request_hash_without_uri.to_json
        elsif body_template && body_template != ""
          # Using a `request` here instead of `request_hash_without_uri`
          # because if `body` is bound to a message field,
          # the fields of the corresponding sub-message,
          # which were used when constructing the URI, should not be deleted
          # (as opposed to the case when `body` is `*`).
          #
          # The `request_hash_without_uri` at this point was mutated to delete these fields.
          #
          # Note 1: body template can only point to a top-level field.
          # Note 2: The field that body template points to can be null, in which case
          # an empty string should be sent. E.g. `Compute.Projects.SetUsageExportBucket`.
          request_body_field = request.send body_template.to_sym if request.respond_to? body_template.to_sym
          if request_body_field
            request_hash_without_uri.delete camel_name_for body_template
            body = request_body_field.to_json emit_defaults: true
          end

          query_params = build_query_params request_hash_without_uri
        else
          query_params = build_query_params request_hash_without_uri
        end

        [body, query_params]
      end

      # Builds query params for the REST request.
      # This function calls itself recursively for every submessage field, passing
      # the submessage hash as request and the path to the submessage field as a prefix.
      # @param request_hash [Hash]
      #   A hash of the GRPC request or the sub-request with the unset
      #   proto3_optional fields and the values that are bound to URI removed.
      # @param prefix [String] A prefix to form the correct query parameter key.
      # @return [Array{String, String}] Query string params as key-value pairs.
      def build_query_params request_hash, prefix = ""
        result = []
        request_hash.each do |key, value|
          full_key_name = "#{prefix}#{key}"
          case value
          when ::Array
            value.each do |_val|
              result.push "#{full_key_name}=#{value}"
            end
          when ::Hash
            result += build_query_params value, "#{full_key_name}."
          else
            result.push "#{full_key_name}=#{value}" unless value.nil?
          end
        end

        result
      end

      # Extracts a non-submessage non-array value from the request hash by path
      # if its string representation matches the regex provided.
      # This method modifies the provided `request_hash` parameter.
      # Returns nil if:
      # - the field is not found
      # - the field is a Message or an array,
      # - the regex does not match
      # @param request_hash [Hash]
      #   A hash of the GRPC request or the sub-request with the unset
      #   proto3_optional fields removed.
      #   !!! This hash will be modified. The extracted field will be deleted. !!!
      # @param field_path [String] A path to the field, e.g. `foo.bar`.
      # @param regex [Regexp] A regex to match on the field's string representation.
      # @return [String, Nil] the field's string representation or nil.
      def extract_scalar_value! request_hash, field_path, regex
        parent, name = find_value request_hash, field_path
        value = parent.delete name

        # Covers the case where in `foo.bar.baz`, `baz` is still a submessage or an array.
        return nil if value.is_a?(::Hash) || value.is_a?(::Array)
        value.to_s if value.to_s =~ regex
      end

      # Finds a value in the hash by path.
      # @param request_hash [Hash] A hash of the GRPC request or the sub-request.
      # @param field_path [String] A path of the field, e.g. `foo.bar`.
      def find_value request_hash, field_path
        path_split = field_path.split "."

        value_parent = nil
        value = request_hash
        last_field_name = nil
        path_split.each do |curr_field|
          # Covers the case when in `foo.bar.baz`, `bar` is not a submessage field
          # or is a submessage field initialized with nil.
          return {}, nil unless value.is_a? ::Hash
          value_parent = value
          last_field_name = curr_field
          value = value[curr_field]
        end

        [value_parent, last_field_name]
      end

      # Performs variable expansion on the template using the bindings provided
      # @param template [String] The Uri template.
      # @param bindings [Hash{String, String}]
      #   The variable bindings. The values should be percent-escaped
      #   (with slashes potentially preserved).
      # @return [String] The expanded template.
      def expand_template template, bindings
        result = template
        bindings.each do |name, value|
          result = result.gsub "{#{name}}", value
        end
        result
      end

      ##
      # Converts a snake_case parameter name into camelCase for query string parameters.
      # @param attr_name [String] Parameter name.
      # @return [String] Camel-cased parameter name.
      def camel_name_for attr_name
        parts = attr_name.split "_"
        first_part = parts[0]
        other_parts = parts[1..]
        other_parts_pascal = other_parts.map(&:capitalize).join
        "#{first_part}#{other_parts_pascal}"
      end
    end
  end
end