connectrpc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +501 -0
- data/docs/design.md +504 -0
- data/docs/development.md +566 -0
- data/docs/grpc-web.md +165 -0
- data/docs/grpc.md +75 -0
- data/docs/rbs.md +469 -0
- data/docs/streaming.md +251 -0
- data/exe/protoc-gen-connectrpc-ruby +23 -0
- data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
- data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
- data/lib/connectrpc/cancellation.rb +220 -0
- data/lib/connectrpc/client.rb +260 -0
- data/lib/connectrpc/compression.rb +120 -0
- data/lib/connectrpc/deadline.rb +81 -0
- data/lib/connectrpc/error.rb +72 -0
- data/lib/connectrpc/generator/names.rb +105 -0
- data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
- data/lib/connectrpc/generator/renderer.rb +115 -0
- data/lib/connectrpc/generator/runtime_names.rb +84 -0
- data/lib/connectrpc/generator/schema.rb +218 -0
- data/lib/connectrpc/generator/streaming_methods.rb +29 -0
- data/lib/connectrpc/generator.rb +42 -0
- data/lib/connectrpc/get_query.rb +159 -0
- data/lib/connectrpc/grpc.rb +200 -0
- data/lib/connectrpc/grpc_client.rb +23 -0
- data/lib/connectrpc/grpc_server.rb +158 -0
- data/lib/connectrpc/grpc_web.rb +131 -0
- data/lib/connectrpc/grpc_web_cors.rb +135 -0
- data/lib/connectrpc/grpc_web_io.rb +156 -0
- data/lib/connectrpc/grpc_web_server.rb +207 -0
- data/lib/connectrpc/instrumentation.rb +216 -0
- data/lib/connectrpc/message_io.rb +47 -0
- data/lib/connectrpc/metadata.rb +105 -0
- data/lib/connectrpc/method.rb +68 -0
- data/lib/connectrpc/protocol.rb +189 -0
- data/lib/connectrpc/server.rb +398 -0
- data/lib/connectrpc/streaming/client.rb +104 -0
- data/lib/connectrpc/streaming/client_execution.rb +87 -0
- data/lib/connectrpc/streaming/client_scope.rb +105 -0
- data/lib/connectrpc/streaming/framing.rb +123 -0
- data/lib/connectrpc/streaming/framing_io.rb +61 -0
- data/lib/connectrpc/streaming/method.rb +33 -0
- data/lib/connectrpc/streaming/server.rb +122 -0
- data/lib/connectrpc/streaming/server_execution.rb +88 -0
- data/lib/connectrpc/streaming/server_terminal.rb +70 -0
- data/lib/connectrpc/streaming/session.rb +143 -0
- data/lib/connectrpc/streaming/session_completion.rb +134 -0
- data/lib/connectrpc/streaming/session_io.rb +107 -0
- data/lib/connectrpc/streaming/session_messages.rb +119 -0
- data/lib/connectrpc/streaming/strategy.rb +95 -0
- data/lib/connectrpc/streaming/streams.rb +116 -0
- data/lib/connectrpc/streaming/terminal.rb +52 -0
- data/lib/connectrpc/transports/net_http.rb +246 -0
- data/lib/connectrpc/version.rb +5 -0
- data/lib/connectrpc.rb +38 -0
- data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
- data/sig/connectrpc.rbs +772 -0
- data/sig/manifest.yaml +2 -0
- data/third_party/README.md +16 -0
- data/third_party/google/LICENSE +201 -0
- data/third_party/google/rpc/status.proto +48 -0
- data/third_party/protobuf-LICENSE +32 -0
- metadata +106 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
class Server # rubocop:disable Metrics/ClassLength
|
|
5
|
+
attr_reader :services
|
|
6
|
+
|
|
7
|
+
# Public configuration keeps each validation next to its assignment.
|
|
8
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
9
|
+
def initialize(services:, binding:, handlers: {}, invoker: nil, request_limit: Protocol::DEFAULT_LIMIT,
|
|
10
|
+
response_limit: Protocol::DEFAULT_LIMIT, request_wire_limit: request_limit,
|
|
11
|
+
response_wire_limit: response_limit, protocol: :connect, mode: :unary, encoding: :identity,
|
|
12
|
+
instrumentation: nil, get_query_limit: nil, terminal_wire_limit: 65_536, terminal_limit: 65_536,
|
|
13
|
+
status_codec: nil, grpc_status_wire_limit: 65_536, grpc_status_limit: 65_536, cors: nil)
|
|
14
|
+
Protocol.profile!(protocol, mode, encoding, server: true)
|
|
15
|
+
@protocol = Protocol.server_protocols(protocol)
|
|
16
|
+
unless cors.nil? || (cors.is_a?(GRPCWebCORS) && @protocol.include?(:grpc_web))
|
|
17
|
+
raise ArgumentError, "cors requires a GRPCWebCORS policy and explicit gRPC-Web enablement"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
@cors = cors
|
|
21
|
+
@grpc_status_wire_limit = Protocol.limit!(grpc_status_wire_limit)
|
|
22
|
+
@grpc_status_limit = Protocol.limit!(grpc_status_limit)
|
|
23
|
+
@status_codec = GRPCProtocol.status_codec!(binding, status_codec) if @protocol.intersect?(%i[grpc grpc_web])
|
|
24
|
+
@terminal_wire_limit = Protocol.limit!(terminal_wire_limit)
|
|
25
|
+
@terminal_limit = Protocol.limit!(terminal_limit)
|
|
26
|
+
@services = services.dup.freeze
|
|
27
|
+
@methods = declarations(services)
|
|
28
|
+
raise ArgumentError, "Handler path is not declared" unless handlers.keys.all? { |key| @methods.key?(key) }
|
|
29
|
+
|
|
30
|
+
@handlers = handlers.dup.freeze
|
|
31
|
+
@invoker = invoker || method(:invoke_handler)
|
|
32
|
+
@instrumentation = instrumentation
|
|
33
|
+
@binding = binding
|
|
34
|
+
@encoding = encoding
|
|
35
|
+
@request_wire_limit = Protocol.limit!(request_wire_limit)
|
|
36
|
+
query_limit = (3 * (4 * ((@request_wire_limit + 2) / 3))) + 1024
|
|
37
|
+
@get_query_limit = Protocol.limit!(get_query_limit.nil? ? query_limit : get_query_limit)
|
|
38
|
+
@response_wire_limit = Protocol.limit!(response_wire_limit)
|
|
39
|
+
@request_limit = Protocol.limit!(request_limit)
|
|
40
|
+
@response_limit = Protocol.limit!(response_limit)
|
|
41
|
+
@error_limit = [@response_limit, @response_wire_limit].min
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
45
|
+
|
|
46
|
+
def handles?(path)
|
|
47
|
+
services.any? { |service| path == "/#{service.name}" || path.start_with?("/#{service.name}/") }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def start_call(path:, headers:, http_method: "POST")
|
|
51
|
+
instrumentation = @instrumentation
|
|
52
|
+
return unless instrumentation
|
|
53
|
+
|
|
54
|
+
instrumentation.start(owner: self, path: path.dup.freeze, role: :server, method: @methods[path],
|
|
55
|
+
carrier: Instrumentation.carrier(headers),
|
|
56
|
+
protocol: request_protocol(headers, http_method))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Keep processing and terminal ensures adjacent to their owner.
|
|
60
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
61
|
+
def call(path:, http_method:, headers:, input:, query: "", lifecycle: nil, cancellation: nil)
|
|
62
|
+
if @protocol.include?(:grpc_web)
|
|
63
|
+
early = cors_response(path: path, http_method: http_method, headers: headers)
|
|
64
|
+
return early if early
|
|
65
|
+
if request_protocol(headers, http_method) == :grpc_web
|
|
66
|
+
return call_grpc_web_unary(path, http_method, headers, input, lifecycle, cancellation)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
unless lifecycle || @instrumentation
|
|
70
|
+
return process_call(path: path, http_method: http_method, headers: headers, input: input, query: query,
|
|
71
|
+
cancellation: cancellation)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
owned = lifecycle.nil?
|
|
75
|
+
lifecycle ||= start_call(path: path, headers: headers, http_method: http_method)
|
|
76
|
+
active = lifecycle #: Instrumentation::Handle
|
|
77
|
+
active.begin_processing(self, path)
|
|
78
|
+
# @type var error: Exception?
|
|
79
|
+
error = nil
|
|
80
|
+
begin
|
|
81
|
+
active.activate do
|
|
82
|
+
process_call(path: path, http_method: http_method, headers: headers, input: input, query: query,
|
|
83
|
+
lifecycle: active, cancellation: cancellation)
|
|
84
|
+
end
|
|
85
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- include escapes from processing cleanup
|
|
86
|
+
error = e
|
|
87
|
+
raise
|
|
88
|
+
ensure
|
|
89
|
+
active.processing_finished(code: nil, error: error) unless active.outcome
|
|
90
|
+
active.finish(delivery: :unobserved) if owned
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def request_protocol(headers, http_method)
|
|
97
|
+
# Query controls own Connect GET, including rejection before header work.
|
|
98
|
+
return :connect if http_method == "GET" && @protocol.include?(:connect)
|
|
99
|
+
|
|
100
|
+
headers = Protocol.headers(headers)
|
|
101
|
+
return :grpc_web if GRPCWeb.intent?(headers)
|
|
102
|
+
|
|
103
|
+
GRPCProtocol.intent?(headers) ? :grpc : :connect
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def process_call(path:, http_method:, headers:, input:, query:, lifecycle: nil, cancellation: nil)
|
|
107
|
+
# @type var response: TransportResponse?
|
|
108
|
+
# @type var context: Context?
|
|
109
|
+
context = nil
|
|
110
|
+
# @type var error: Exception?
|
|
111
|
+
error = nil
|
|
112
|
+
# @type var cleaned: bool
|
|
113
|
+
cleaned = false
|
|
114
|
+
begin
|
|
115
|
+
cancellation&.check!
|
|
116
|
+
return response = bare(415, {}, cancellation: cancellation) unless @protocol.include?(:connect)
|
|
117
|
+
|
|
118
|
+
method = @methods[path]
|
|
119
|
+
safe = method.is_a?(Method) && method.idempotency == :no_side_effects
|
|
120
|
+
unless http_method == "POST" || (http_method == "GET" && (!method || safe))
|
|
121
|
+
return response = bare(405, { "allow" => [safe ? "GET, POST" : "POST"] }, cancellation: cancellation)
|
|
122
|
+
end
|
|
123
|
+
if http_method == "GET"
|
|
124
|
+
return response = process_get(path, headers, input, query, lifecycle, cancellation) do |created|
|
|
125
|
+
context = created
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
headers = Protocol.headers(headers)
|
|
130
|
+
codec = Protocol.content_codec(Protocol.single(headers, "content-type"))
|
|
131
|
+
unless codec
|
|
132
|
+
return response = bare(415, { "accept-post" => ["application/proto, application/json"] },
|
|
133
|
+
cancellation: cancellation)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
decoded_context = context = request_context(headers, lifecycle, cancellation)
|
|
137
|
+
response = dispatch(path, headers, input, codec, decoded_context, lifecycle: lifecycle)
|
|
138
|
+
rescue Error => e
|
|
139
|
+
lifecycle&.record_failure(e)
|
|
140
|
+
response = error_response(terminal_error(e, context, cancellation), context, lifecycle)
|
|
141
|
+
rescue StandardError => e
|
|
142
|
+
lifecycle&.record_failure(e)
|
|
143
|
+
safe = terminal_error(Error.new(:internal, "Internal error"), context, cancellation)
|
|
144
|
+
response = error_response(safe, context, lifecycle)
|
|
145
|
+
ensure
|
|
146
|
+
context&.close
|
|
147
|
+
vary_get(response) if http_method == "GET" && response
|
|
148
|
+
cleaned = true
|
|
149
|
+
end
|
|
150
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- include rescue handlers and cleanup in the escape boundary
|
|
151
|
+
error = e
|
|
152
|
+
raise
|
|
153
|
+
ensure
|
|
154
|
+
if lifecycle && !lifecycle.outcome
|
|
155
|
+
lifecycle.processing_finished(code: response&.status == 200 && cleaned && !error ? "OK" : lifecycle.code,
|
|
156
|
+
error: error,
|
|
157
|
+
normalized_error: lifecycle.normalized_error,
|
|
158
|
+
http_status: response&.status, response_bytes: response&.body&.bytesize,
|
|
159
|
+
response_metadata: response ? Instrumentation.carrier(response.headers) : {})
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
164
|
+
|
|
165
|
+
# GET negotiation and dispatch preserve the specified rejection order.
|
|
166
|
+
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
167
|
+
def process_get(path, headers, input, query, lifecycle, state) # rubocop:disable Metrics/ParameterLists
|
|
168
|
+
Protocol.check_size!(query, @get_query_limit)
|
|
169
|
+
headers = Protocol.headers(headers)
|
|
170
|
+
# Start valid deadlines before query work, but defer invalid timeout errors
|
|
171
|
+
# until after codec/body/compression/version negotiation.
|
|
172
|
+
timeout_error = nil #: Error?
|
|
173
|
+
deadline = begin
|
|
174
|
+
request_deadline(headers)
|
|
175
|
+
rescue Error => e
|
|
176
|
+
timeout_error = e
|
|
177
|
+
nil
|
|
178
|
+
end
|
|
179
|
+
context = Context.new(deadline: deadline, lifecycle: lifecycle, cancellation: state)
|
|
180
|
+
yield context
|
|
181
|
+
values = GetQuery.parse(query, limit: @get_query_limit, wire_limit: @request_wire_limit, deadline: context)
|
|
182
|
+
codecs = { "proto" => :proto, "json" => :json } #: Hash[String, codec]
|
|
183
|
+
codec = codecs[values["encoding"] || ""]
|
|
184
|
+
return bare(415, { "accept-post" => ["application/proto, application/json"] }, context: context) unless codec
|
|
185
|
+
unless empty_get_body?(headers, input, context)
|
|
186
|
+
return bare(415, { "accept-post" => ["application/proto, application/json"] }, context: context)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
negotiated = get_headers(headers, values)
|
|
190
|
+
raise Error.new(:invalid_argument, "Missing GET message") unless values.key?("message")
|
|
191
|
+
raise timeout_error if timeout_error
|
|
192
|
+
|
|
193
|
+
metadata = Protocol.metadata(headers)
|
|
194
|
+
lifecycle.request_metadata = Instrumentation.carrier(metadata) if lifecycle
|
|
195
|
+
metadata.each { |key, values| context.request_metadata[key] = values }
|
|
196
|
+
context.check!
|
|
197
|
+
method = @methods[path]
|
|
198
|
+
raise Error.new(:unimplemented, "Unknown method") unless method.is_a?(Method)
|
|
199
|
+
|
|
200
|
+
bytes = GetQuery.payload(values, limit: @request_wire_limit, deadline: context)
|
|
201
|
+
lifecycle.request_bytes = bytes.bytesize if lifecycle
|
|
202
|
+
bytes = inflate_request(bytes, negotiated, context)
|
|
203
|
+
request = decode_request(bytes, method, codec, context)
|
|
204
|
+
response = @invoker.call(method, request, context)
|
|
205
|
+
encoding = Protocol.response_encoding(negotiated, Protocol.content_encoding(negotiated), @encoding)
|
|
206
|
+
encode_response(response, method, codec, context, encoding)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
210
|
+
|
|
211
|
+
def empty_get_body?(headers, input, deadline)
|
|
212
|
+
deadline&.check!
|
|
213
|
+
length = Protocol.content_length!(headers["content-length"], invalid_code: :invalid_argument)
|
|
214
|
+
return false if length&.positive?
|
|
215
|
+
|
|
216
|
+
bytes = input&.read(1)
|
|
217
|
+
deadline&.check!
|
|
218
|
+
bytes.nil? || bytes.empty?
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def get_headers(headers, values)
|
|
222
|
+
negotiated = headers.except("content-type", "content-encoding", "connect-protocol-version")
|
|
223
|
+
coding = values["compression"]
|
|
224
|
+
negotiated["content-encoding"] = [coding] if coding && !coding.empty?
|
|
225
|
+
# Validate coding before version, matching the GET negotiation order.
|
|
226
|
+
validate_headers!(negotiated)
|
|
227
|
+
version = values["connect"]
|
|
228
|
+
if version && !version.empty? && version != "v1"
|
|
229
|
+
raise Error.new(:invalid_argument, "Unsupported Connect protocol version")
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
negotiated
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def vary_get(response)
|
|
236
|
+
values = response.headers.fetch("vary", [])
|
|
237
|
+
tokens = values.flat_map { |value| value.split(",").map(&:strip) }
|
|
238
|
+
return if tokens.any? { |token| token == "*" || token.casecmp?("accept-encoding") }
|
|
239
|
+
|
|
240
|
+
response.headers["vary"] = values + ["Accept-Encoding"]
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def declarations(services)
|
|
244
|
+
raise ArgumentError, "Invalid service declarations" unless services.is_a?(Array) && services.all?(Service)
|
|
245
|
+
raise ArgumentError, "Duplicate service" unless services.map(&:name).uniq.size == services.size
|
|
246
|
+
|
|
247
|
+
services.flat_map(&:methods).to_h { |method| [method.path, method] }.freeze
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def invoke_handler(method, request, context)
|
|
251
|
+
handler = @handlers[method.path]
|
|
252
|
+
raise Error.new(:unimplemented, "Unknown method") unless handler
|
|
253
|
+
|
|
254
|
+
unary = handler #: _Handler[untyped, untyped]
|
|
255
|
+
unary.call(request, context)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def dispatch(path, headers, input, codec, context, lifecycle: nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
259
|
+
context.check!
|
|
260
|
+
method = @methods[path]
|
|
261
|
+
raise Error.new(:unimplemented, "Unknown method") unless method.is_a?(Method)
|
|
262
|
+
|
|
263
|
+
length = Protocol.content_length!(headers["content-length"], invalid_code: :invalid_argument)
|
|
264
|
+
bytes = MessageIO.new(input, @request_wire_limit, context).read(length)
|
|
265
|
+
lifecycle.request_bytes = bytes.bytesize if lifecycle
|
|
266
|
+
bytes = inflate_request(bytes, headers, context)
|
|
267
|
+
request = decode_request(bytes, method, codec, context)
|
|
268
|
+
response = @invoker.call(method, request, context)
|
|
269
|
+
encoding = Protocol.response_encoding(headers, Protocol.content_encoding(headers), @encoding)
|
|
270
|
+
encode_response(response, method, codec, context, encoding)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def inflate_request(bytes, headers, context)
|
|
274
|
+
if Protocol.content_encoding(headers) == :gzip && !bytes.empty?
|
|
275
|
+
bytes = Compression.decode(bytes, limit: @request_limit, deadline: context,
|
|
276
|
+
on_error: ->(error) { context.record_failure(error) })
|
|
277
|
+
end
|
|
278
|
+
Protocol.check_size!(bytes, @request_limit)
|
|
279
|
+
bytes
|
|
280
|
+
rescue Compression::Malformed => e
|
|
281
|
+
context.record_failure(e)
|
|
282
|
+
raise Error.new(:invalid_argument, "Cannot decompress request")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def decode_request(bytes, method, codec, context)
|
|
286
|
+
context.check!
|
|
287
|
+
request = @binding.decode(bytes, type: method.request_type, codec: codec)
|
|
288
|
+
context.check!
|
|
289
|
+
request
|
|
290
|
+
rescue CodecError => e
|
|
291
|
+
context.record_failure(e)
|
|
292
|
+
raise Error.new(:invalid_argument, "Cannot decode request")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def encode_response(message, method, codec, context, encoding) # rubocop:disable Metrics/MethodLength
|
|
296
|
+
body = serialize_response(message, method, codec, context)
|
|
297
|
+
headers = response_headers(context, Protocol::CONTENT_TYPES.fetch(codec))
|
|
298
|
+
if encoding == :gzip
|
|
299
|
+
body = Compression.encode(body, limit: @response_wire_limit, deadline: context,
|
|
300
|
+
on_error: ->(error) { context.record_failure(error) })
|
|
301
|
+
headers["content-encoding"] = ["gzip"]
|
|
302
|
+
end
|
|
303
|
+
Protocol.check_size!(body, @response_wire_limit)
|
|
304
|
+
context.check!
|
|
305
|
+
context.commit!
|
|
306
|
+
TransportResponse.new(status: 200, headers: headers, body: body)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def serialize_response(message, method, codec, context)
|
|
310
|
+
context.check!
|
|
311
|
+
body = @binding.encode(message, type: method.response_type, codec: codec)
|
|
312
|
+
context.check!
|
|
313
|
+
Protocol.check_size!(body, @response_limit)
|
|
314
|
+
body
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def bare(status, headers, context: nil, cancellation: nil)
|
|
318
|
+
context ? context.commit! : cancellation&.check!
|
|
319
|
+
TransportResponse.new(status: status, headers: headers, body: "")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def request_context(headers, lifecycle = nil, cancellation = nil)
|
|
323
|
+
validate_headers!(headers)
|
|
324
|
+
metadata = Protocol.metadata(headers)
|
|
325
|
+
lifecycle.request_metadata = Instrumentation.carrier(metadata) if lifecycle
|
|
326
|
+
Context.new(request_metadata: metadata, deadline: request_deadline(headers), lifecycle: lifecycle,
|
|
327
|
+
cancellation: cancellation)
|
|
328
|
+
rescue ArgumentError => e
|
|
329
|
+
lifecycle&.record_failure(e)
|
|
330
|
+
raise Error.new(:invalid_argument, "Malformed request metadata")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def validate_headers!(headers)
|
|
334
|
+
if headers.key?("connect-protocol-version") && Protocol.single(headers, "connect-protocol-version") != "1"
|
|
335
|
+
raise Error.new(:invalid_argument, "Unsupported Connect protocol version")
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
encoding = Protocol.content_encoding(headers)
|
|
339
|
+
return if encoding == :identity || (encoding == :gzip && @encoding == :gzip)
|
|
340
|
+
|
|
341
|
+
supported = @encoding == :gzip ? "gzip, identity" : "identity"
|
|
342
|
+
raise Error.new(:unimplemented, "Supported content encodings: #{supported}")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def request_deadline(headers)
|
|
346
|
+
return unless headers.key?("connect-timeout-ms")
|
|
347
|
+
|
|
348
|
+
value = Protocol.single(headers, "connect-timeout-ms")
|
|
349
|
+
unless value && /\A[0-9]{1,10}\z/.match?(value) && value.to_i.positive?
|
|
350
|
+
raise Error.new(:invalid_argument, "Invalid Connect timeout")
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
Deadline.new(value.to_i / 1000.0)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def response_headers(context, content_type)
|
|
357
|
+
# @type var headers: headers
|
|
358
|
+
headers = context ? context.response_metadata.to_h : {}
|
|
359
|
+
context&.response_trailers&.each { |key, values| headers["trailer-#{key}"] = values }
|
|
360
|
+
headers["accept-encoding"] = ["gzip, identity"] if @encoding == :gzip
|
|
361
|
+
headers.merge("content-type" => [content_type])
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def terminal_error(error, context, cancellation)
|
|
365
|
+
if context
|
|
366
|
+
context.commit!
|
|
367
|
+
else
|
|
368
|
+
cancellation&.check!
|
|
369
|
+
end
|
|
370
|
+
error
|
|
371
|
+
rescue Error => e
|
|
372
|
+
e
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def error_response(error, context, lifecycle = nil) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
|
376
|
+
# @type var effective: Error?
|
|
377
|
+
# @type var code: String?
|
|
378
|
+
effective = code = nil
|
|
379
|
+
body, status = if lifecycle
|
|
380
|
+
Protocol.error_body(error, @error_limit) do |selected, emitted|
|
|
381
|
+
effective = selected
|
|
382
|
+
code = emitted
|
|
383
|
+
end
|
|
384
|
+
else
|
|
385
|
+
Protocol.error_body(error, @error_limit)
|
|
386
|
+
end
|
|
387
|
+
headers = response_headers(context, "application/json")
|
|
388
|
+
error.metadata.each { |key, values| headers[key] = (headers[key] || []) + values }
|
|
389
|
+
error.trailers.each { |key, values| headers["trailer-#{key}"] = (headers["trailer-#{key}"] || []) + values }
|
|
390
|
+
response = TransportResponse.new(status: status, headers: headers, body: body)
|
|
391
|
+
if lifecycle
|
|
392
|
+
lifecycle.code = code
|
|
393
|
+
lifecycle.normalized_error = effective
|
|
394
|
+
end
|
|
395
|
+
response
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
class Client
|
|
5
|
+
def server_stream(method, request, metadata: {}, timeout: 30, cancellation: nil, &block)
|
|
6
|
+
validate_stream_call!(method, ServerStreamMethod, cancellation, block)
|
|
7
|
+
stream_call(method, request, metadata, timeout, cancellation, &block)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def client_stream(method, metadata: {}, timeout: 30, cancellation: nil, &block)
|
|
11
|
+
validate_stream_call!(method, ClientStreamMethod, cancellation, block)
|
|
12
|
+
stream_call(method, nil, metadata, timeout, cancellation, &block)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def bidi_stream(method, metadata: {}, timeout: 30, cancellation: nil, &block)
|
|
16
|
+
validate_stream_call!(method, BidiStreamMethod, cancellation, block)
|
|
17
|
+
stream_call(method, nil, metadata, timeout, cancellation, &block)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def validate_stream_call!(method, type, cancellation, block)
|
|
23
|
+
raise ArgumentError, "Wrong streaming declaration" unless method.is_a?(type)
|
|
24
|
+
raise ArgumentError, "A streaming block is required" unless block
|
|
25
|
+
unless cancellation.nil? || cancellation.is_a?(CancellationToken)
|
|
26
|
+
raise ArgumentError, "cancellation must be a CancellationToken or nil"
|
|
27
|
+
end
|
|
28
|
+
return if stream_capability?(method)
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "Transport does not support #{method.cardinality}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stream_capability?(method)
|
|
34
|
+
if @protocol == :grpc
|
|
35
|
+
candidate = @transport #: (_Transport | _StreamingTransport | _GRPCTransport) & _GRPCCapabilityProbe
|
|
36
|
+
return false unless candidate.respond_to?(:grpc_capabilities) && candidate.respond_to?(:open_grpc_stream)
|
|
37
|
+
|
|
38
|
+
grpc = @transport #: _GRPCTransport
|
|
39
|
+
return grpc.grpc_capabilities.include?(method.cardinality)
|
|
40
|
+
end
|
|
41
|
+
capability = @transport #: _StreamingTransport
|
|
42
|
+
streaming_transport? && capability.streaming_capabilities.include?(method.cardinality)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def streaming_transport?
|
|
46
|
+
# Runtime reflection is confined here; unary structural callers need only call.
|
|
47
|
+
transport = @transport #: (_Transport | _StreamingTransport) & _StreamCapabilityProbe
|
|
48
|
+
transport.respond_to?(:streaming_capabilities) && transport.respond_to?(:open_stream)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stream_call(method, request, metadata, timeout, cancellation, &)
|
|
52
|
+
injection = {} #: headers
|
|
53
|
+
lifecycle = @instrumentation&.start(owner: self, role: :client, method: method, carrier: injection,
|
|
54
|
+
protocol: @protocol,
|
|
55
|
+
server_address: @base_url.hostname.freeze, server_port: @base_url.port)
|
|
56
|
+
lifecycle&.begin_processing(self)
|
|
57
|
+
work = lambda do
|
|
58
|
+
run_stream(method, request, metadata, timeout: timeout, cancellation: cancellation,
|
|
59
|
+
lifecycle: lifecycle, injection: injection, &)
|
|
60
|
+
end
|
|
61
|
+
lifecycle ? lifecycle.activate(&work) : work.call
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def run_stream(method, request, metadata, timeout:, cancellation:, lifecycle:, injection:, &) # rubocop:disable Metrics/MethodLength
|
|
65
|
+
scope = Streaming::ClientScope.new(lifecycle)
|
|
66
|
+
scope.run(timeout, cancellation) do |state, deadline|
|
|
67
|
+
outgoing = stream_request(method, stream_metadata(metadata, injection, lifecycle), deadline, state, lifecycle)
|
|
68
|
+
transport = @transport #: _StreamingTransport
|
|
69
|
+
work = lambda do |exchange|
|
|
70
|
+
session = new_client_stream_session(method, state, exchange, lifecycle)
|
|
71
|
+
with_stream_session(session, scope) { perform_stream_scope(method, request, session, scope, &) }
|
|
72
|
+
end
|
|
73
|
+
if @protocol == :grpc
|
|
74
|
+
grpc = @transport #: _GRPCTransport
|
|
75
|
+
grpc.open_grpc_stream(outgoing, &work)
|
|
76
|
+
else
|
|
77
|
+
transport.open_stream(outgoing, &work)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stream_metadata(metadata, injection, lifecycle)
|
|
83
|
+
metadata = Metadata.new(metadata)
|
|
84
|
+
metadata = injected_metadata(metadata, injection) if lifecycle
|
|
85
|
+
lifecycle.request_metadata = Instrumentation.carrier(metadata) if lifecycle
|
|
86
|
+
metadata
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def stream_request(method, metadata, deadline, state, lifecycle) # rubocop:disable Metrics/MethodLength
|
|
90
|
+
url = @base_url.dup
|
|
91
|
+
base_path = @base_url.path #: String
|
|
92
|
+
url.path = base_path.delete_suffix("/") + method.path
|
|
93
|
+
return grpc_stream_request(url, metadata, deadline, state, lifecycle) if @protocol == :grpc
|
|
94
|
+
|
|
95
|
+
headers = metadata.to_h.merge("content-type" => [Streaming::CONTENT_TYPES.fetch(@codec)],
|
|
96
|
+
"connect-protocol-version" => ["1"],
|
|
97
|
+
"connect-accept-encoding" => [@accept_encoding.join(", ")])
|
|
98
|
+
headers["connect-content-encoding"] = ["gzip"] if @encoding == :gzip
|
|
99
|
+
headers["connect-timeout-ms"] = [deadline.timeout_ms] if deadline
|
|
100
|
+
StreamRequest.new(url: url, http_method: "POST", headers: headers, deadline: deadline,
|
|
101
|
+
cancellation: state, lifecycle: lifecycle)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConnectRPC
|
|
4
|
+
class Client
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def new_client_stream_session(method, state, exchange, lifecycle)
|
|
8
|
+
Streaming::Session.new(binding: @binding, method: method, codec: @codec, state: state,
|
|
9
|
+
input: exchange, output: exchange, request_limit: @request_limit,
|
|
10
|
+
response_limit: @response_limit, request_wire_limit: @request_wire_limit,
|
|
11
|
+
response_wire_limit: @response_wire_limit, request_encoding: @encoding,
|
|
12
|
+
response_encoding: :identity, accept_encoding: @accept_encoding,
|
|
13
|
+
terminal_wire_limit: @terminal_wire_limit, terminal_limit: @terminal_limit,
|
|
14
|
+
lifecycle: lifecycle, strategy: client_stream_strategy(lifecycle))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def with_stream_session(session, scope) # rubocop:disable Metrics/MethodLength
|
|
18
|
+
yield
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
scope.failure = e
|
|
21
|
+
scope.delivered_error = e if session.remote_completed?(e)
|
|
22
|
+
recovered = session.recover_send_failure if session.send_failure?(e)
|
|
23
|
+
if recovered
|
|
24
|
+
scope.delivered_error = recovered if session.remote_completed?(recovered)
|
|
25
|
+
raise recovered, cause: nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
raise
|
|
29
|
+
ensure
|
|
30
|
+
scope.cleanup(session)
|
|
31
|
+
session.close
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def perform_stream_scope(method, request, session, scope)
|
|
35
|
+
prepare_server_stream(method, request, session)
|
|
36
|
+
yield_stream_endpoint(session, scope) { yield stream_endpoint(method, session) }
|
|
37
|
+
scope.abandoned = session.abandoned?
|
|
38
|
+
scope.result = finish_stream_scope(method, session)
|
|
39
|
+
scope.completed = !scope.abandoned
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def prepare_server_stream(method, request, session)
|
|
43
|
+
return unless method.is_a?(ServerStreamMethod) || method.is_a?(Method)
|
|
44
|
+
|
|
45
|
+
session.send_message(request)
|
|
46
|
+
session.close_send
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def yield_stream_endpoint(session, scope)
|
|
50
|
+
yield
|
|
51
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- Preserve fatal user exceptions through transport cleanup.
|
|
52
|
+
scope.record_user_error(e) unless session.owned_error?(e)
|
|
53
|
+
raise
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def stream_endpoint(method, session)
|
|
57
|
+
case method
|
|
58
|
+
when ServerStreamMethod then ResponseReader.new(session)
|
|
59
|
+
when ClientStreamMethod then RequestWriter.new(session)
|
|
60
|
+
else ClientDuplex.new(session)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def finish_stream_scope(method, session)
|
|
65
|
+
return client_stream_response(session) if method.is_a?(ClientStreamMethod) || method.is_a?(Method)
|
|
66
|
+
return if session.abandoned?
|
|
67
|
+
return session.receive if session.consumed?
|
|
68
|
+
|
|
69
|
+
raise StreamNotConsumedError, "Response stream was not consumed"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def client_stream_response(session)
|
|
73
|
+
raise StreamNotConsumedError, "Client streaming was abandoned" if session.abandoned?
|
|
74
|
+
|
|
75
|
+
session.close_send
|
|
76
|
+
message = session.receive
|
|
77
|
+
raise Error.new(:unimplemented, "Expected exactly one response"), cause: nil if message.nil?
|
|
78
|
+
|
|
79
|
+
if @protocol == :grpc
|
|
80
|
+
session.receive until session.consumed?
|
|
81
|
+
else
|
|
82
|
+
session.receive
|
|
83
|
+
end
|
|
84
|
+
Response.new(message: message, metadata: session.headers, trailers: session.trailers || Metadata.new)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|