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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +501 -0
  4. data/docs/design.md +504 -0
  5. data/docs/development.md +566 -0
  6. data/docs/grpc-web.md +165 -0
  7. data/docs/grpc.md +75 -0
  8. data/docs/rbs.md +469 -0
  9. data/docs/streaming.md +251 -0
  10. data/exe/protoc-gen-connectrpc-ruby +23 -0
  11. data/lib/connectrpc/bindings/descriptors/status.bin +15 -0
  12. data/lib/connectrpc/bindings/google_protobuf.rb +92 -0
  13. data/lib/connectrpc/cancellation.rb +220 -0
  14. data/lib/connectrpc/client.rb +260 -0
  15. data/lib/connectrpc/compression.rb +120 -0
  16. data/lib/connectrpc/deadline.rb +81 -0
  17. data/lib/connectrpc/error.rb +72 -0
  18. data/lib/connectrpc/generator/names.rb +105 -0
  19. data/lib/connectrpc/generator/rbs_renderer.rb +93 -0
  20. data/lib/connectrpc/generator/renderer.rb +115 -0
  21. data/lib/connectrpc/generator/runtime_names.rb +84 -0
  22. data/lib/connectrpc/generator/schema.rb +218 -0
  23. data/lib/connectrpc/generator/streaming_methods.rb +29 -0
  24. data/lib/connectrpc/generator.rb +42 -0
  25. data/lib/connectrpc/get_query.rb +159 -0
  26. data/lib/connectrpc/grpc.rb +200 -0
  27. data/lib/connectrpc/grpc_client.rb +23 -0
  28. data/lib/connectrpc/grpc_server.rb +158 -0
  29. data/lib/connectrpc/grpc_web.rb +131 -0
  30. data/lib/connectrpc/grpc_web_cors.rb +135 -0
  31. data/lib/connectrpc/grpc_web_io.rb +156 -0
  32. data/lib/connectrpc/grpc_web_server.rb +207 -0
  33. data/lib/connectrpc/instrumentation.rb +216 -0
  34. data/lib/connectrpc/message_io.rb +47 -0
  35. data/lib/connectrpc/metadata.rb +105 -0
  36. data/lib/connectrpc/method.rb +68 -0
  37. data/lib/connectrpc/protocol.rb +189 -0
  38. data/lib/connectrpc/server.rb +398 -0
  39. data/lib/connectrpc/streaming/client.rb +104 -0
  40. data/lib/connectrpc/streaming/client_execution.rb +87 -0
  41. data/lib/connectrpc/streaming/client_scope.rb +105 -0
  42. data/lib/connectrpc/streaming/framing.rb +123 -0
  43. data/lib/connectrpc/streaming/framing_io.rb +61 -0
  44. data/lib/connectrpc/streaming/method.rb +33 -0
  45. data/lib/connectrpc/streaming/server.rb +122 -0
  46. data/lib/connectrpc/streaming/server_execution.rb +88 -0
  47. data/lib/connectrpc/streaming/server_terminal.rb +70 -0
  48. data/lib/connectrpc/streaming/session.rb +143 -0
  49. data/lib/connectrpc/streaming/session_completion.rb +134 -0
  50. data/lib/connectrpc/streaming/session_io.rb +107 -0
  51. data/lib/connectrpc/streaming/session_messages.rb +119 -0
  52. data/lib/connectrpc/streaming/strategy.rb +95 -0
  53. data/lib/connectrpc/streaming/streams.rb +116 -0
  54. data/lib/connectrpc/streaming/terminal.rb +52 -0
  55. data/lib/connectrpc/transports/net_http.rb +246 -0
  56. data/lib/connectrpc/version.rb +5 -0
  57. data/lib/connectrpc.rb +38 -0
  58. data/rbs/google_protobuf_4_36/runtime.rbs +41 -0
  59. data/sig/connectrpc.rbs +772 -0
  60. data/sig/manifest.yaml +2 -0
  61. data/third_party/README.md +16 -0
  62. data/third_party/google/LICENSE +201 -0
  63. data/third_party/google/rpc/status.proto +48 -0
  64. data/third_party/protobuf-LICENSE +32 -0
  65. 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