async-grpc-compatible 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e1a8ff0cff0bfc7725848538d6bc490b6dff6e7ffdd5bcd96d0dc6461b6c918c
4
+ data.tar.gz: a0377f5efac4796f5f8bee1d1384ea19a0bd3e61a71a0eb758fe22a4ddf245ac
5
+ SHA512:
6
+ metadata.gz: 6edb95034ed047c101473b1ec6a360d1e9832940273796c7f20bf8271304bb39e6e34a41ce825e062f2b91142767452c9aee23e0bc9631ce0fbf2f7c61854d9d
7
+ data.tar.gz: 8166f1fe7114ab3a62530b9b3c026208e797c54457186bbce318ce12d938378e6052863894649092b641c238aa6f2d8ee8860cb2a358c8ddc87f3ed3309d567a
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "io/endpoint/tls/configuration"
7
+
8
+ module Async
9
+ module GRPC
10
+ module Compatible
11
+ # Maps gRPC channel credentials to transport-neutral TLS configurations.
12
+ module ChannelCredentials
13
+ # Create a TLS configuration using the positional arguments of `GRPC::Core::ChannelCredentials.new`.
14
+ # @parameter root_certificates [String | Nil] The trusted root certificates encoded as a PEM bundle, or nil to use the transport's default trust store.
15
+ # @parameter private_key [String | Nil] The client private key encoded as PEM.
16
+ # @parameter certificate_chain [String | Nil] The client certificate chain encoded as a PEM bundle, with the leaf certificate first.
17
+ # @returns [IO::Endpoint::TLS::Configuration] The transport-neutral TLS configuration.
18
+ # @raises [ArgumentError] If a certificate bundle is empty or the client certificate chain and private key are not supplied together.
19
+ # @raises [TypeError] If certificate or private key material is not a string.
20
+ def self.new(root_certificates = nil, private_key = nil, certificate_chain = nil)
21
+ trust_store = unless root_certificates.nil?
22
+ IO::Endpoint::TLS::TrustStore.parse(root_certificates)
23
+ end
24
+
25
+ certificates = unless certificate_chain.nil?
26
+ IO::Endpoint::TLS::Certificates.parse(certificate_chain)
27
+ end
28
+
29
+ IO::Endpoint::TLS::Configuration.new(
30
+ trust_store: trust_store,
31
+ certificate_chain: certificates,
32
+ private_key: private_key,
33
+ verification: :peer
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,494 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async/grpc"
7
+ require "async/http/endpoint"
8
+ require "async/http/protocol/http2"
9
+ require "base64"
10
+ require "openssl"
11
+ require "grpc"
12
+ require "io/endpoint/tls/configuration"
13
+ require "protocol/grpc/body/readable"
14
+ require "protocol/grpc/body/writable"
15
+ require "protocol/grpc/metadata"
16
+ require_relative "channel_credentials"
17
+ require_relative "operation"
18
+
19
+ ::Thread.attr_accessor :async_grpc_compatible_shared_clients
20
+
21
+ module Async
22
+ module GRPC
23
+ module Compatible
24
+ # Represents a reusable Async gRPC channel.
25
+ class Channel
26
+ # Initialize a channel for the given endpoint.
27
+ # @parameter endpoint [Async::HTTP::Endpoint | Nil] The remote HTTP/2 endpoint, inferred from a supplied Async client when possible.
28
+ # @parameter client [Async::GRPC::Client | Nil] An existing client to use.
29
+ def initialize(endpoint = nil, client: nil)
30
+ @endpoint = endpoint
31
+ if @endpoint.nil? && client.is_a?(Async::GRPC::Client) && client.delegate.respond_to?(:endpoint)
32
+ @endpoint = client.delegate.endpoint
33
+ end
34
+ @client = client || Async::GRPC::Client.open(endpoint)
35
+ @owned = client.nil?
36
+ end
37
+
38
+ # @attribute [Async::HTTP::Endpoint | Nil] The remote endpoint.
39
+ attr_reader :endpoint
40
+
41
+ # @attribute [Async::GRPC::Client] The shared Async gRPC client.
42
+ attr_reader :client
43
+
44
+ # Close the underlying client when it is owned by this channel.
45
+ def close
46
+ @client.close if @owned
47
+ end
48
+ end
49
+
50
+ # Represents a channel whose client is shared with other shared channels for the same URL and TLS configuration on the same thread.
51
+ #
52
+ # Each thread has its own client because an Async connection pool belongs to a single reactor.
53
+ class SharedChannel < Channel
54
+ # @returns [Hash] The current thread's shared clients, keyed by URL and TLS configuration.
55
+ def self.clients
56
+ ::Thread.current.async_grpc_compatible_shared_clients ||= {}
57
+ end
58
+
59
+ # Close the current thread's shared clients. Shared channels open new clients on their next call.
60
+ def self.close
61
+ # Detach the clients before closing them, since closing may yield to a call which opens a new client:
62
+ clients = ::Thread.current.async_grpc_compatible_shared_clients
63
+ ::Thread.current.async_grpc_compatible_shared_clients = nil
64
+
65
+ clients&.each_value(&:close)
66
+ end
67
+
68
+ # Initialize a shared channel for the given URL and TLS configuration.
69
+ #
70
+ # The channel freezes a copy of the TLS configuration, so later changes by the caller do not affect shared clients.
71
+ #
72
+ # @parameter url [String] The remote `http` or `https` URL.
73
+ # @parameter tls_configuration [IO::Endpoint::TLS::Configuration | Nil] The TLS configuration for an `https` URL.
74
+ def initialize(url, tls_configuration = nil)
75
+ tls_configuration = tls_configuration&.dup&.freeze
76
+ @endpoint = Async::HTTP::Endpoint.parse(url, protocol: Async::HTTP::Protocol::HTTP2, tls_configuration: tls_configuration)
77
+ @key = ["#{@endpoint.scheme}://#{@endpoint.authority}", tls_configuration]
78
+ end
79
+
80
+ # @attribute [Async::GRPC::Client] The current thread's client for this endpoint.
81
+ def client
82
+ self.class.clients[@key] ||= Async::GRPC::Client.open(@endpoint)
83
+ end
84
+
85
+ # Leave the shared client open for other channels.
86
+ def close
87
+ end
88
+ end
89
+
90
+ # Represents a subset of `GRPC::ClientStub` backed by {Async::GRPC::Client}.
91
+ class ClientStub
92
+ INSECURE_CREDENTIALS = :this_channel_is_insecure
93
+ LOCAL_SUBCHANNEL_POOL = "grpc.use_local_subchannel_pool"
94
+ DEFAULT_TIMEOUT = nil
95
+
96
+ # Transport failures which end a call without a gRPC status. grpc-ruby reports these as `UNAVAILABLE`.
97
+ TRANSPORT_ERRORS = [
98
+ ::Protocol::HTTP::RefusedError,
99
+ ::Protocol::HTTP2::Error,
100
+ ::Protocol::HPACK::Error,
101
+ IOError,
102
+ SocketError,
103
+ SystemCallError,
104
+ OpenSSL::SSL::SSLError,
105
+ ].freeze
106
+
107
+ # The gRPC status for each HTTP/2 stream reset code, from gRPC's HTTP/2 status mapping. Other codes map to `INTERNAL`.
108
+ STREAM_RESET_STATUSES = {
109
+ ::Protocol::HTTP2::Error::REFUSED_STREAM => ::GRPC::Core::StatusCodes::UNAVAILABLE,
110
+ ::Protocol::HTTP2::Error::CANCEL => ::GRPC::Core::StatusCodes::CANCELLED,
111
+ ::Protocol::HTTP2::Error::ENHANCE_YOUR_CALM => ::GRPC::Core::StatusCodes::RESOURCE_EXHAUSTED,
112
+ ::Protocol::HTTP2::Error::INADEQUATE_SECURITY => ::GRPC::Core::StatusCodes::PERMISSION_DENIED,
113
+ }.freeze
114
+
115
+ # Build a compatible stub class for a generated GRPC::GenericService.
116
+ # @parameter service [Class] The generated service definition.
117
+ # @returns [Class] A client stub with methods for the service's unary RPCs.
118
+ def self.for(service)
119
+ Class.new(self) do
120
+ service.rpc_descs.each do |name, description|
121
+ method_name = ::GRPC::GenericService.underscore(name.to_s)
122
+ path = "/#{service.service_name}/#{name}"
123
+ marshal = description.marshal_proc
124
+ unmarshal = description.unmarshal_proc(:output)
125
+ define_method(method_name) do |request, **options|
126
+ raise NotImplementedError, "Streaming RPCs are not yet supported!" unless description.request_response?
127
+ request_response(path, request, marshal, unmarshal, **options)
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ # Construct a compatible channel.
134
+ #
135
+ # Without an override, the channel shares connections with other stubs for the same target and TLS configuration on the current thread. Set the `grpc.use_local_subchannel_pool` channel argument to a non-zero value to use a client owned by this channel instead.
136
+ #
137
+ # @parameter channel_override [Channel, Async::GRPC::Client | Nil] An existing compatible channel or client.
138
+ # @parameter host [String] The gRPC target.
139
+ # @parameter credentials [IO::Endpoint::TLS::Configuration, Symbol] The channel TLS configuration or insecure marker.
140
+ # @parameter channel_arguments [Hash] gRPC channel arguments.
141
+ # @returns [Channel] The compatible channel.
142
+ def self.setup_channel(channel_override, host, credentials, channel_arguments = {})
143
+ case channel_override
144
+ when Channel
145
+ return channel_override
146
+ when Async::GRPC::Client
147
+ return Channel.new(client: channel_override)
148
+ when nil
149
+ # Continue constructing the channel:
150
+ else
151
+ raise TypeError, "Channel override must be an Async::GRPC::Compatible::Channel or Async::GRPC::Client!"
152
+ end
153
+
154
+ # grpc-ruby accepts string and symbol keys. Only fall back on nil, so an explicit false is preserved:
155
+ local_pool = channel_arguments[LOCAL_SUBCHANNEL_POOL]
156
+ local_pool = channel_arguments[LOCAL_SUBCHANNEL_POOL.to_sym] if local_pool.nil?
157
+
158
+ # gRPC uses integer boolean flags (0/1). Ruby treats 0 as truthy, so check it explicitly:
159
+ if local_pool && local_pool != 0
160
+ Channel.new(endpoint_for(host, credentials, channel_arguments))
161
+ else
162
+ SharedChannel.new(url_for(host, credentials), tls_configuration_for(credentials))
163
+ end
164
+ end
165
+
166
+ # Construct an HTTP/2 endpoint for a gRPC target.
167
+ #
168
+ # Channels with a local subchannel pool use this endpoint. Shared channels are identified by their URL and TLS configuration, so they are constructed from {url_for} and {tls_configuration_for} instead.
169
+ #
170
+ # @parameter host [String] The gRPC target.
171
+ # @parameter credentials [IO::Endpoint::TLS::Configuration, Symbol] The channel TLS configuration or insecure marker.
172
+ # @parameter channel_arguments [Hash] gRPC channel arguments.
173
+ # @returns [Async::HTTP::Endpoint] The HTTP/2 endpoint.
174
+ def self.endpoint_for(host, credentials, channel_arguments = {})
175
+ Async::HTTP::Endpoint.parse(url_for(host, credentials), protocol: Async::HTTP::Protocol::HTTP2, tls_configuration: tls_configuration_for(credentials))
176
+ end
177
+
178
+ # Construct the URL for a gRPC target.
179
+ # @parameter host [String] The gRPC target.
180
+ # @parameter credentials [IO::Endpoint::TLS::Configuration, Symbol] The channel TLS configuration or insecure marker.
181
+ # @returns [String] The `http` or `https` URL.
182
+ def self.url_for(host, credentials)
183
+ raise TypeError, "Host must be a String!" unless host.is_a?(String)
184
+
185
+ scheme = scheme_for(credentials)
186
+ target = normalize_target(host)
187
+
188
+ if target.match?(/\Ahttps?:\/\//)
189
+ url = target
190
+ else
191
+ url = "#{scheme}://#{target}"
192
+ end
193
+
194
+ raise ArgumentError, "Target scheme must match the channel credentials!" unless url.start_with?("#{scheme}://")
195
+
196
+ return url
197
+ end
198
+
199
+ # Construct the TLS configuration for the given credentials.
200
+ # @parameter credentials [IO::Endpoint::TLS::Configuration, Symbol] The channel TLS configuration or insecure marker.
201
+ # @returns [IO::Endpoint::TLS::Configuration | Nil] The TLS configuration, or `nil` for insecure credentials.
202
+ def self.tls_configuration_for(credentials)
203
+ return nil if scheme_for(credentials) == "http"
204
+
205
+ IO::Endpoint::TLS::Configuration.new(
206
+ trust_store: credentials.trust_store,
207
+ certificate_chain: credentials.certificate_chain,
208
+ private_key: credentials.private_key,
209
+ verification: credentials.verification || :peer
210
+ )
211
+ end
212
+
213
+ # Determine the URL scheme for the given credentials.
214
+ # @parameter credentials [IO::Endpoint::TLS::Configuration, Symbol] The channel TLS configuration or insecure marker.
215
+ # @returns [String] Either `"http"` or `"https"`.
216
+ def self.scheme_for(credentials)
217
+ return "http" if credentials == INSECURE_CREDENTIALS
218
+
219
+ if credentials.is_a?(IO::Endpoint::TLS::Configuration)
220
+ return "https"
221
+ end
222
+
223
+ raise TypeError, "Credentials must be IO::Endpoint::TLS::Configuration or :this_channel_is_insecure; native gRPC credentials are unsupported!"
224
+ end
225
+
226
+ # Normalize a grpc-ruby target into an HTTP authority.
227
+ # @parameter host [String] The gRPC target.
228
+ # @returns [String] The normalized target.
229
+ def self.normalize_target(host)
230
+ if host.start_with?("dns:///")
231
+ host.delete_prefix("dns:///")
232
+ elsif host.start_with?("dns://")
233
+ host.delete_prefix("dns://").delete_prefix("/")
234
+ elsif host.match?(/\A(?:unix|unix-abstract|ipv4|ipv6|xds|passthrough):/i)
235
+ raise ArgumentError, "Unsupported gRPC target: #{host.inspect}!"
236
+ else
237
+ host
238
+ end
239
+ end
240
+
241
+ # Create a compatible client stub.
242
+ # @parameter host [String] The gRPC target.
243
+ # @parameter credentials [IO::Endpoint::TLS::Configuration, Symbol, Proc, Object, Nil] The channel TLS configuration, insecure marker, or Ruby authentication callback. Callbacks use a default verified TLS channel. Nil requires a channel override.
244
+ # @parameter channel_override [Channel, Async::GRPC::Client | Nil] An existing compatible channel or client.
245
+ # @parameter timeout [Numeric | Nil] The default relative timeout in seconds.
246
+ # @parameter propagate_mask [Integer | Nil] Reserved for grpc-ruby compatibility.
247
+ # @parameter channel_args [Hash] gRPC channel arguments.
248
+ # @parameter call_credentials [Proc | Object | Nil] An authentication callback or an object with updater_proc.
249
+ # @parameter interceptors [Array] grpc-ruby client interceptors, which are not yet supported.
250
+ def initialize(host, credentials,
251
+ channel_override: nil,
252
+ timeout: nil,
253
+ propagate_mask: nil,
254
+ channel_args: {},
255
+ interceptors: [],
256
+ call_credentials: nil)
257
+ raise NotImplementedError, "Client interceptors are not yet supported!" unless interceptors.empty?
258
+
259
+ if credentials.respond_to?(:updater_proc) || credentials.respond_to?(:call)
260
+ raise ArgumentError, "Supply call credentials only once!" if call_credentials
261
+ call_credentials = credentials
262
+ credentials = IO::Endpoint::TLS::Configuration.new(verification: :peer)
263
+ end
264
+ self.class.scheme_for(credentials) unless credentials.nil? && channel_override
265
+ @call_credentials = call_credentials
266
+ channel_arguments = channel_args.dup
267
+ @channel = self.class.setup_channel(channel_override, host, credentials, channel_arguments)
268
+ @owned_channel = channel_override.nil?
269
+ @timeout = timeout
270
+ @propagate_mask = propagate_mask
271
+ end
272
+
273
+ # @attribute [Channel] The compatible channel.
274
+ attr_reader :channel
275
+ attr_writer :propagate_mask
276
+
277
+ # Send a unary request and return its response.
278
+ # @parameter method [String] The fully qualified RPC path.
279
+ # @parameter request [Object] The request object.
280
+ # @parameter marshal [Proc] A callable which encodes the request.
281
+ # @parameter unmarshal [Proc] A callable which decodes the response.
282
+ # @parameter deadline [Time | Nil] The absolute call deadline.
283
+ # @parameter return_op [Boolean] Whether to return an operation object.
284
+ # @parameter parent [Object | Nil] A parent server call.
285
+ # @parameter credentials [Object | Nil] Per-call credentials.
286
+ # @parameter metadata [Hash] Request metadata.
287
+ # @returns [Object] The decoded response.
288
+ # @raises [GRPC::BadStatus] If the call fails.
289
+ def request_response(method, request, marshal, unmarshal,
290
+ deadline: nil,
291
+ return_op: false,
292
+ parent: nil,
293
+ credentials: nil,
294
+ metadata: {})
295
+ raise NotImplementedError, "Parent call propagation is not yet supported!" if parent
296
+
297
+ timeout = relative_timeout(deadline)
298
+ call_deadline = timeout && Time.now + timeout
299
+ operation = Operation.new(deadline: call_deadline) do |operation|
300
+ execute_request_response(method, request, marshal, unmarshal, metadata, credentials, operation)
301
+ end
302
+ return operation if return_op
303
+
304
+ operation.execute
305
+ end
306
+
307
+ # Close a channel created by this stub. Shared clients remain open for other stubs.
308
+ def close
309
+ @channel.close if @owned_channel
310
+ end
311
+
312
+ private
313
+
314
+ def execute_request_response(method, request, marshal, unmarshal, metadata, credentials, operation)
315
+ timeout = operation.deadline && operation.deadline - Time.now
316
+ raise_deadline_exceeded if timeout && timeout <= 0
317
+
318
+ Sync do |task|
319
+ if timeout
320
+ task.with_timeout(timeout, Async::GRPC::DeadlineExceededError) do
321
+ metadata = update_metadata(metadata, credentials, method)
322
+ invoke_request_response(method, request, marshal, unmarshal, metadata, timeout, operation)
323
+ end
324
+ else
325
+ metadata = update_metadata(metadata, credentials, method)
326
+ invoke_request_response(method, request, marshal, unmarshal, metadata, nil, operation)
327
+ end
328
+ end
329
+ rescue Async::GRPC::DeadlineExceededError
330
+ raise_deadline_exceeded
331
+ rescue Async::GRPC::ResponseError => error
332
+ status = Protocol::GRPC::Status.for_http_status(error.response.status)
333
+ raise_bad_status(status, error.message, {}, cause: error)
334
+ rescue Protocol::GRPC::Error => error
335
+ raise_bad_status(error.status_code, error.cause&.message || error.message, error.metadata, cause: error)
336
+ end
337
+
338
+ def update_metadata(metadata, credentials, method)
339
+ metadata = normalize_metadata(metadata)
340
+ [@call_credentials, credentials].compact.each do |updater|
341
+ updater = updater.updater_proc if updater.respond_to?(:updater_proc)
342
+ raise TypeError, "Call credentials must be callable or expose updater_proc!" unless updater.respond_to?(:call)
343
+
344
+ endpoint = @channel.endpoint
345
+ raise ArgumentError, "Call credentials require a secure channel with a known endpoint!" unless endpoint && endpoint.scheme == "https"
346
+ service = normalize_method(method).rpartition("/").first
347
+ context = {jwt_aud_uri: "https://#{endpoint.authority}#{service}"}
348
+ attributes = updater.call(context)
349
+ next if attributes.nil?
350
+ raise TypeError, "Call credentials must return a Hash or nil!" unless attributes.is_a?(Hash)
351
+
352
+ # Google updaters can return the context along with authentication headers.
353
+ attributes.each do |key, value|
354
+ key = key.to_s
355
+ metadata[key] = value unless key == "jwt_aud_uri"
356
+ end
357
+ end
358
+ metadata
359
+ end
360
+
361
+ def invoke_request_response(method, request, marshal, unmarshal, metadata, timeout, operation)
362
+ body = Protocol::GRPC::Body::Writable.new
363
+ payload = marshal.call(request)
364
+ raise TypeError, "Marshal must return a String!" unless payload.is_a?(String)
365
+
366
+ body.write(payload)
367
+ body.close_write
368
+
369
+ timeout = operation.deadline && operation.deadline - Time.now
370
+ raise_deadline_exceeded if timeout && timeout <= 0
371
+
372
+ headers = build_headers(
373
+ metadata: normalize_metadata(metadata),
374
+ timeout: timeout,
375
+ content_type: "application/grpc"
376
+ )
377
+ request = Protocol::HTTP::Request["POST", normalize_method(method), headers, body]
378
+ payload = perform_request(request, operation)
379
+
380
+ payload ? unmarshal.call(payload) : nil
381
+ end
382
+
383
+ # Send the request and read its response payload. Application callbacks run outside this method, so their errors are not treated as transport failures.
384
+ def perform_request(request, operation)
385
+ response = @channel.client.call(request)
386
+
387
+ begin
388
+ operation.metadata = extract_metadata(Protocol::HTTP::Headers.new(response.headers.header.to_a, policy: Protocol::GRPC::HEADER_POLICY))
389
+ response_encoding = response.headers["grpc-encoding"]
390
+ response_body = Protocol::GRPC::Body::Readable.wrap(response, encoding: response_encoding)
391
+ payload = response_body&.read
392
+ response_body&.finish
393
+
394
+ operation.trailing_metadata = extract_metadata(Protocol::HTTP::Headers.new(response.headers.trailer.to_a, policy: Protocol::GRPC::HEADER_POLICY))
395
+ operation.status = ::Struct::Status.new(
396
+ Protocol::GRPC::Metadata.extract_status(response.headers),
397
+ Protocol::GRPC::Metadata.extract_message(response.headers),
398
+ operation.trailing_metadata
399
+ )
400
+ check_status!(response)
401
+
402
+ payload
403
+ ensure
404
+ response.close
405
+ end
406
+ rescue ::Protocol::HTTP2::StreamError => error
407
+ status = STREAM_RESET_STATUSES.fetch(error.code, ::GRPC::Core::StatusCodes::INTERNAL)
408
+ raise_bad_status(status, error.message, {}, cause: error)
409
+ rescue ::Protocol::HTTP::RemoteError => error
410
+ # The peer reset the stream with `INTERNAL_ERROR`:
411
+ raise_bad_status(::GRPC::Core::StatusCodes::INTERNAL, error.message, {}, cause: error)
412
+ rescue *TRANSPORT_ERRORS => error
413
+ raise_bad_status(::GRPC::Core::StatusCodes::UNAVAILABLE, error.message, {}, cause: error)
414
+ end
415
+
416
+ def check_status!(response)
417
+ status = Protocol::GRPC::Metadata.extract_status(response.headers)
418
+ return if status == Protocol::GRPC::Status::OK
419
+
420
+ details = Protocol::GRPC::Metadata.extract_message(response.headers)
421
+ metadata = extract_metadata(response.headers)
422
+ raise_bad_status(status, details, metadata)
423
+ end
424
+
425
+ def build_headers(metadata:, timeout:, content_type:)
426
+ headers = Protocol::HTTP::Headers.new(policy: Protocol::GRPC::HEADER_POLICY)
427
+ headers["content-type"] = content_type
428
+ headers["te"] = "trailers"
429
+ headers["grpc-timeout"] = timeout if timeout
430
+
431
+ metadata.each do |key, value|
432
+ headers[key] = if key.end_with?("-bin")
433
+ Base64.strict_encode64(value)
434
+ else
435
+ value.to_s
436
+ end
437
+ end
438
+
439
+ return headers
440
+ end
441
+
442
+ def extract_metadata(headers)
443
+ Protocol::GRPC::Metadata.extract(headers)
444
+ end
445
+
446
+ def normalize_method(method)
447
+ method = method.to_s
448
+ method.start_with?("/") ? method : "/#{method}"
449
+ end
450
+
451
+ def normalize_metadata(metadata)
452
+ metadata.to_h.each_with_object({}) do |(key, value), normalized|
453
+ normalized[key.to_s] = value
454
+ end
455
+ end
456
+
457
+ def relative_timeout(deadline)
458
+ return relative_default_timeout if deadline.nil?
459
+
460
+ if defined?(::GRPC::Core::TimeConsts::INFINITE_FUTURE) && deadline == ::GRPC::Core::TimeConsts::INFINITE_FUTURE
461
+ return nil
462
+ end
463
+
464
+ if defined?(::GRPC::Core::TimeConsts::ZERO) && deadline == ::GRPC::Core::TimeConsts::ZERO
465
+ return 0
466
+ end
467
+
468
+ if deadline.respond_to?(:to_time)
469
+ deadline.to_time - Time.now
470
+ elsif deadline.is_a?(Numeric)
471
+ deadline
472
+ else
473
+ raise TypeError, "Deadline must be a Time or Numeric value!"
474
+ end
475
+ end
476
+
477
+ def relative_default_timeout
478
+ return nil if @timeout.nil? || @timeout < 0
479
+
480
+ @timeout
481
+ end
482
+
483
+ def raise_deadline_exceeded
484
+ raise_bad_status(::GRPC::Core::StatusCodes::DEADLINE_EXCEEDED, "Deadline exceeded!", {})
485
+ end
486
+
487
+ def raise_bad_status(status, details, metadata, cause: nil)
488
+ error = ::GRPC::BadStatus.new_status_exception(status, details || "Unknown cause!", metadata)
489
+ raise error, cause: cause
490
+ end
491
+ end
492
+ end
493
+ end
494
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "gapic/grpc"
7
+ require_relative "client_stub"
8
+
9
+ module Async
10
+ module GRPC
11
+ module Compatible
12
+ # Represents a GAPIC service stub that preserves Ruby credential updaters.
13
+ class GapicServiceStub < ::Gapic::ServiceStub
14
+ # Initialize a GAPIC adapter for a generated service definition.
15
+ # @parameter service [Class] The generated GRPC::GenericService definition.
16
+ # @parameter channel [Channel | Nil] An optional shared Async channel.
17
+ # @parameter options [Hash] GAPIC service options, including credentials and endpoint.
18
+ def initialize(service, channel: nil, **options)
19
+ @service_name = service.service_name
20
+ @async_channel = channel
21
+ super(ClientStub.for(service), **options)
22
+ end
23
+
24
+ # Construct the Async stub before GAPIC converts credentials into opaque native objects.
25
+ # @parameter grpc_stub_class [Class] The compatible stub class.
26
+ # @parameter endpoint [String] The service endpoint.
27
+ # @parameter credentials [Object] The original GAPIC credentials.
28
+ # @parameter channel_args [Hash | Nil] Channel arguments.
29
+ # @parameter interceptors [Array | Nil] Client interceptors.
30
+ def create_grpc_stub(grpc_stub_class, endpoint:, credentials:, channel_args: nil, interceptors: nil)
31
+ @grpc_stub = grpc_stub_class.new(endpoint, credentials,
32
+ channel_override: @async_channel,
33
+ channel_args: channel_args || {},
34
+ interceptors: interceptors || [])
35
+ end
36
+
37
+ # Async::HTTP owns connection pooling; native GAPIC channel pools are unsupported.
38
+ def create_channel_pool(...)
39
+ raise ArgumentError, "Use a shared Async channel instead of a GAPIC channel pool!"
40
+ end
41
+
42
+ # Close the underlying stub's owned connection pool, leaving shared clients open.
43
+ def close
44
+ @grpc_stub&.close
45
+ end
46
+
47
+ # Supply a service identity for the anonymous generated stub class.
48
+ # @private
49
+ def setup_logging(system_name: nil, service: nil, **options)
50
+ super(system_name: "async-grpc-compatible", service: @service_name, **options)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "async"
7
+ require "grpc"
8
+
9
+ module Async
10
+ module GRPC
11
+ module Compatible
12
+ # Represents a deferred unary call. Execute and cancel active calls in the same reactor.
13
+ class Operation
14
+ # Initialize a deferred call.
15
+ # @parameter deadline [Time | Nil] The absolute call deadline.
16
+ # @yields {|operation| ...} Executes the request and records its response.
17
+ def initialize(deadline: nil, &execute)
18
+ @deadline = deadline
19
+ @execute = execute
20
+ @mutex = Thread::Mutex.new
21
+ @executed = false
22
+ @finished = false
23
+ @cancelled = false
24
+ @task = nil
25
+ @thread = nil
26
+ @metadata = nil
27
+ @trailing_metadata = nil
28
+ @status = nil
29
+ end
30
+
31
+ # @attribute [Time | Nil] The absolute call deadline.
32
+ attr_reader :deadline
33
+ # @attribute [Hash | Nil] The initial response metadata.
34
+ attr_accessor :metadata
35
+ # @attribute [Hash | Nil] The response trailers.
36
+ attr_accessor :trailing_metadata
37
+ # @attribute [Struct::Status | Nil] The completed call status.
38
+ attr_accessor :status
39
+
40
+ # Execute this operation once, waiting for the response.
41
+ # @returns [Object] The decoded response.
42
+ # @raises [GRPC::BadStatus] If the RPC fails or is cancelled.
43
+ def execute
44
+ @mutex.synchronize do
45
+ raise RuntimeError, "Operation has already been executed!" if @executed
46
+ @executed = true
47
+ end
48
+
49
+ begin
50
+ Sync do |parent|
51
+ task = @mutex.synchronize do
52
+ raise ::GRPC::Cancelled.new("Cancelled!") if @cancelled
53
+ @thread = Thread.current
54
+ @task = Async::Task.new(parent, finished: false){@execute.call(self)}
55
+ end
56
+
57
+ task.run
58
+ result = task.wait
59
+ raise ::GRPC::Cancelled.new("Cancelled!") if cancelled?
60
+ result
61
+ ensure
62
+ task&.stop
63
+ end
64
+ rescue ::GRPC::BadStatus => error
65
+ @status = error.to_status
66
+ raise
67
+ ensure
68
+ @mutex.synchronize do
69
+ @finished = true
70
+ @task = nil
71
+ end
72
+ @execute = nil
73
+ end
74
+ end
75
+
76
+ # Cancel a pending or active operation from its reactor.
77
+ def cancel
78
+ task = @mutex.synchronize do
79
+ return if @finished
80
+ raise ThreadError, "Cancel the operation from its reactor thread!" if @task && @thread != Thread.current
81
+ @cancelled = true
82
+ @task
83
+ end
84
+ task&.stop
85
+ end
86
+
87
+ # Whether cancellation was requested or reported by the server.
88
+ # @returns [Boolean] Whether the operation was cancelled.
89
+ def cancelled?
90
+ @mutex.synchronize{@cancelled || @status&.code == ::GRPC::Core::StatusCodes::CANCELLED}
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ # @namespace
7
+ module Async
8
+ # @namespace
9
+ module GRPC
10
+ # @namespace
11
+ module Compatible
12
+ VERSION = "0.1.0"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require_relative "compatible/version"
7
+ require_relative "compatible/client_stub"
8
+
9
+ module Async
10
+ module GRPC
11
+ # Provides grpc-ruby compatible client interfaces backed by {Async::GRPC}.
12
+ module Compatible
13
+ end
14
+ end
15
+ end
data/license.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright, 2026, by Samuel Williams.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/readme.md ADDED
@@ -0,0 +1,189 @@
1
+ # Async::GRPC::Compatible
2
+
3
+ grpc-ruby compatible client interfaces backed by `async-grpc` and `async-http`.
4
+
5
+ The gem is intended for generated clients which currently construct a `GRPC::ClientStub`, but need to make non-blocking calls inside an Async event loop. Connection pooling and HTTP/2 multiplexing remain the responsibility of `async-http`; stubs for the same target share its connection pool, as described in [Connection sharing](#connection-sharing).
6
+
7
+ The gem depends on `grpc` for service definitions and error types. TLS configuration comes from `IO::Endpoint`, and requests use Async's connection pool.
8
+
9
+ ## Usage
10
+
11
+ Please see the [project documentation](https://socketry.github.io/async-grpc-compatible/) for more details.
12
+
13
+ ## Current Compatibility
14
+
15
+ The initial implementation supports:
16
+
17
+ - The grpc-ruby `GRPC::ClientStub.new` parameter shape.
18
+ - Unary `request_response` calls.
19
+ - Custom marshal and unmarshal callables.
20
+ - Request metadata and deadlines.
21
+ - Insecure endpoints and TLS using `IO::Endpoint::TLS::Configuration`, including custom trust roots and client certificates, with `Compatible::ChannelCredentials.new` mapping gRPC's positional PEM arguments.
22
+ - Translation of gRPC failures into `GRPC::BadStatus` subclasses.
23
+ - Deferred unary operations using `return_op: true`.
24
+ - Ruby credential updaters supplied through `call_credentials:`, `credentials:`, or a credential object with `updater_proc`.
25
+ - Unary stub generation from `GRPC::GenericService` definitions.
26
+ - Connection sharing between stubs for the same target, with `grpc.use_local_subchannel_pool` to opt out.
27
+
28
+ The following are not yet supported:
29
+
30
+ - Client, server, or bidirectional streaming.
31
+ - grpc-ruby interceptors.
32
+ - Parent call propagation.
33
+ - Native `GRPC::Core::ChannelCredentials`, `GRPC::Core::CallCredentials`, composed credentials, and native channel overrides. These are rejected because their TLS configuration and authentication callbacks cannot be recovered through Ruby's public API.
34
+ - grpc-ruby channel arguments other than `grpc.use_local_subchannel_pool`.
35
+ - Non-DNS resolvers such as Unix sockets and xDS.
36
+
37
+ Invalid HTTP responses become `GRPC::BadStatus` subclasses using the HTTP status mapping. The error details describe the invalid HTTP status and content type, and `error.cause` is an `Async::GRPC::ResponseError` whose `response` exposes the HTTP status, headers, and buffered body. Call `error.cause.response.read` to read that body.
38
+
39
+ ## Connection sharing
40
+
41
+ Stubs constructed without a channel override share connections, similar to grpc-core's global subchannel pool. Stubs on the same thread use one `Async::GRPC::Client` when they connect to the same target with the same TLS configuration. TLS configurations are compared by value, so separately constructed but identical configurations share a client, and different trust roots, client certificates, or verification policies never do. Constructing a stub or GAPIC client per request or per job therefore reuses warm connections within the same Async reactor instead of performing a new TLS and HTTP/2 handshake.
42
+
43
+ Each thread has its own shared client, because an Async connection pool belongs to a single reactor. The client is selected when each call runs, so a stub shared between threads uses the calling thread's connections. Connections do not outlive their reactor. Calls made outside a reactor each run in a temporary reactor, so run related calls inside one `Sync` or `Async` block to reuse connections.
44
+
45
+ Closing a stub leaves shared clients open for other stubs. Call `Async::GRPC::Compatible::SharedChannel.close` to close the current thread's shared clients, for example when a worker thread shuts down; later calls open new clients. To give a stub a connection pool of its own, which `close` releases, set grpc's local subchannel pool argument:
46
+
47
+ ``` ruby
48
+ stub = Async::GRPC::Compatible::ClientStub.new(
49
+ "grpc.example.com:443", IO::Endpoint::TLS::Configuration.new,
50
+ channel_args: {"grpc.use_local_subchannel_pool" => 1}
51
+ )
52
+ ```
53
+
54
+ Pass `channel_override:` (or `channel:` to `GapicServiceStub`) to control connection reuse explicitly. The caller owns a channel supplied this way. Shared channels are built from the target URL and TLS configuration alone, so subclasses which override `ClientStub.endpoint_for`, for example to connect through a custom transport, only take effect with a local subchannel pool; alternatively, pass `channel_override: Async::GRPC::Compatible::Channel.new(endpoint)`.
55
+
56
+ ## Operations and credentials
57
+
58
+ Pass `return_op: true` to defer a unary call until `operation.execute`. An operation executes once and exposes `deadline`, `metadata`, `trailing_metadata`, `status`, `cancel`, and `cancelled?`. The deadline includes time spent waiting to execute. Cancel an active operation from the same Async reactor; cancelling it closes that call without closing a shared channel. Calling `cancel` after completion has no effect.
59
+
60
+ Supply `call_credentials:` to the constructor for a default authentication callback, or `credentials:` to `request_response` for a per-call callback. Objects exposing `updater_proc`, such as Google authentication credentials, are also accepted. Callbacks run at execution time on every call, so token refreshes are used.
61
+
62
+ Each callback receives a fresh authentication context containing `:jwt_aud_uri`, for example `https://grpc.example.com/example.Service`. The audience uses the actual channel's endpoint and RPC service path. Return a hash of authentication headers, or `nil` to add none. Returned headers are merged into a copy of the caller's metadata; per-call credentials run after constructor credentials. The audience context is never sent as a header.
63
+
64
+ Authentication callbacks require a TLS channel with a known endpoint. A shared `Async::GRPC::Client` supplies its endpoint through its HTTP delegate; for a custom client, supply the endpoint explicitly when constructing `Compatible::Channel.new(endpoint, client: client)`.
65
+
66
+ ``` ruby
67
+ stub = Async::GRPC::Compatible::ClientStub.new(
68
+ "grpc.example.com:443",
69
+ IO::Endpoint::TLS::Configuration.new,
70
+ call_credentials: credentials.updater_proc
71
+ )
72
+ ```
73
+
74
+ Passing an authentication callback as the constructor's second argument creates a default TLS channel with peer and hostname verification. For custom trust roots or mutual TLS, provide the TLS configuration separately:
75
+
76
+ ``` ruby
77
+ tls = IO::Endpoint::TLS::Configuration.new(
78
+ trust_store: IO::Endpoint::TLS::TrustStore.load("ca.pem"),
79
+ certificate_chain: IO::Endpoint::TLS::Certificates.parse(File.read("client-chain.pem")),
80
+ private_key: File.read("client-key.pem")
81
+ )
82
+
83
+ stub = Async::GRPC::Compatible::ClientStub.new(
84
+ "grpc.example.com:443", tls,
85
+ call_credentials: credentials.updater_proc
86
+ )
87
+ ```
88
+
89
+ TLS channels verify peers and hostnames by default, including localhost. An explicit URL must match the selected transport: TLS credentials require `https://`, and `:this_channel_is_insecure` requires `http://`.
90
+
91
+ ### gRPC TLS configuration
92
+
93
+ `Async::GRPC::Compatible::ChannelCredentials.new` accepts the same three optional positional PEM arguments as `GRPC::Core::ChannelCredentials.new` and returns an `IO::Endpoint::TLS::Configuration` directly:
94
+
95
+ | gRPC constructor argument | TLS configuration |
96
+ | --- | --- |
97
+ | Root certificates | `trust_store`, containing only the supplied roots |
98
+ | Client private key | `private_key` |
99
+ | Client certificate chain | `certificate_chain`, split into individual certificates in the supplied order |
100
+
101
+ ``` ruby
102
+ tls = Async::GRPC::Compatible::ChannelCredentials.new(
103
+ File.read("ca.pem"),
104
+ File.read("client-key.pem"),
105
+ File.read("client-chain.pem")
106
+ )
107
+
108
+ stub = Async::GRPC::Compatible::ClientStub.new("grpc.example.com:443", tls)
109
+ ```
110
+
111
+ Use `ChannelCredentials.new` for the transport's default trust store, or `ChannelCredentials.new(root_pem)` for custom roots without a client identity. The client key and certificate chain must be supplied together. Peer and hostname verification are always enabled by this mapping. gRPC-specific default-root overrides are not read; supply those roots explicitly.
112
+
113
+ Pass the returned TLS configuration to `ClientStub` or `GapicServiceStub` at construction. Existing native credential objects cannot be converted through Ruby's public API, so retain the PEM inputs at that boundary. Supply Ruby authentication callbacks separately using `call_credentials:` on `ClientStub`.
114
+
115
+ ## GAPIC and generated Google clients
116
+
117
+ Require the optional adapter after installing your Google client gem (which provides `gapic-common`). `GapicServiceStub` accepts the generated service definition and preserves the original credential updater before GAPIC wraps it in native credentials. Its `call_rpc` path retains GAPIC's retry policies and yields the completed operation to the caller.
118
+
119
+ ``` ruby
120
+ require "google/cloud/kms/v1"
121
+ require "google/cloud/kms/v1/service_services_pb"
122
+ require "async/grpc/compatible/gapic"
123
+
124
+ credentials = Google::Auth.get_application_default(
125
+ ["https://www.googleapis.com/auth/cloud-platform"]
126
+ )
127
+
128
+ Sync do
129
+ service = Async::GRPC::Compatible::GapicServiceStub.new(
130
+ Google::Cloud::Kms::V1::KeyManagementService::Service,
131
+ endpoint: "cloudkms.googleapis.com",
132
+ credentials: credentials
133
+ )
134
+
135
+ begin
136
+ request = Google::Cloud::Kms::V1::EncryptRequest.new(
137
+ name: "projects/my-project/locations/global/keyRings/my-ring/cryptoKeys/my-key",
138
+ plaintext: "hello"
139
+ )
140
+ response = service.call_rpc(:encrypt, request, options: {timeout: 5}) do |response, operation|
141
+ puts operation.status.code
142
+ end
143
+ puts response.ciphertext.bytesize
144
+ ensure
145
+ service.close
146
+ end
147
+ end
148
+ ```
149
+
150
+ Without `channel:`, adapters share connections as described in [Connection sharing](#connection-sharing). For an existing Async connection pool, pass `channel:` to the adapter. The caller owns that channel. GAPIC native channel pools are unsupported because Async::HTTP already manages connections.
151
+
152
+ Generated high-level Google clients construct `Gapic::ServiceStub` inside their constructors. Applications adapting those constructors can use `GapicServiceStub.new(Service, credentials: original_credentials, ...)` at that construction point. The adapter can also be called directly, as above, without replacing global GRPC constants. Keep the original Ruby credentials available at this boundary; credentials already composed into `GRPC::Core::ChannelCredentials` cannot be recovered.
153
+
154
+ For generated services without GAPIC, use `ClientStub.for(Service)` to construct a stub class with unary RPC methods. Streaming methods raise `NotImplementedError`. This adapter supports the operation methods listed above; native operation controls such as `start_call`, `wait`, and write flags are not implemented.
155
+
156
+ ## Development
157
+
158
+ Run the test suite:
159
+
160
+ ``` shell
161
+ $ bundle exec sus
162
+ ```
163
+
164
+ The integration suite includes a unary client fixture generated by
165
+ NuevoProtobuf 2.5.4 and exercises it against an in-process Async HTTP/2 server.
166
+
167
+ ## Releases
168
+
169
+ Please see the [project releases](https://socketry.github.io/async-grpc-compatible/releases/index) for all releases.
170
+
171
+ ### v0.1.0
172
+
173
+ - Send `application/grpc` and use shared metadata decoding, including unpadded binary metadata.
174
+ - Map invalid HTTP responses to grpc-ruby errors, preserving the native `ResponseError` and its buffered response as the cause.
175
+ - Support deferred unary operations with execution, cancellation, deadline, status, and response metadata access.
176
+ - Support Ruby authentication callbacks at stub construction and per call, evaluated on each execution with the service's JWT audience and merged into request metadata.
177
+ - Support custom trust roots and mutual TLS through `IO::Endpoint::TLS::Configuration`. Reject opaque native credentials, conflicting target schemes, and authentication callbacks on plaintext channels.
178
+ - Map grpc-ruby's TLS constructor arguments with `Compatible::ChannelCredentials.new(root_certificates, private_key, certificate_chain)`, returning an `IO::Endpoint::TLS::Configuration` with custom roots, client certificate chains, and peer verification enabled.
179
+ - Add `ClientStub.for(service)` and the optional `GapicServiceStub` adapter for generated services and GAPIC clients.
180
+ - Map transport failures to grpc-ruby errors, preserving the original exception as the cause. Connection, DNS, TLS, and HTTP/2 connection failures become `GRPC::Unavailable`, and HTTP/2 stream resets use gRPC's HTTP/2 status mapping.
181
+ - Share connections between stubs for the same target and TLS configuration on each thread, like grpc-core's global subchannel pool. Closing a stub leaves shared clients open; use `SharedChannel.close` to close the current thread's shared clients, or set `grpc.use_local_subchannel_pool` for a stub-owned connection pool.
182
+
183
+ ### v0.0.0
184
+
185
+ - Initial implementation of an Async-backed `GRPC::ClientStub` compatible unary client.
186
+
187
+ ## License
188
+
189
+ Released under the MIT License.
data/releases.md ADDED
@@ -0,0 +1,17 @@
1
+ # Releases
2
+
3
+ ## v0.1.0
4
+
5
+ - Send `application/grpc` and use shared metadata decoding, including unpadded binary metadata.
6
+ - Map invalid HTTP responses to grpc-ruby errors, preserving the native `ResponseError` and its buffered response as the cause.
7
+ - Support deferred unary operations with execution, cancellation, deadline, status, and response metadata access.
8
+ - Support Ruby authentication callbacks at stub construction and per call, evaluated on each execution with the service's JWT audience and merged into request metadata.
9
+ - Support custom trust roots and mutual TLS through `IO::Endpoint::TLS::Configuration`. Reject opaque native credentials, conflicting target schemes, and authentication callbacks on plaintext channels.
10
+ - Map grpc-ruby's TLS constructor arguments with `Compatible::ChannelCredentials.new(root_certificates, private_key, certificate_chain)`, returning an `IO::Endpoint::TLS::Configuration` with custom roots, client certificate chains, and peer verification enabled.
11
+ - Add `ClientStub.for(service)` and the optional `GapicServiceStub` adapter for generated services and GAPIC clients.
12
+ - Map transport failures to grpc-ruby errors, preserving the original exception as the cause. Connection, DNS, TLS, and HTTP/2 connection failures become `GRPC::Unavailable`, and HTTP/2 stream resets use gRPC's HTTP/2 status mapping.
13
+ - Share connections between stubs for the same target and TLS configuration on each thread, like grpc-core's global subchannel pool. Closing a stub leaves shared clients open; use `SharedChannel.close` to close the current thread's shared clients, or set `grpc.use_local_subchannel_pool` for a stub-owned connection pool.
14
+
15
+ ## v0.0.0
16
+
17
+ - Initial implementation of an Async-backed `GRPC::ClientStub` compatible unary client.
metadata ADDED
@@ -0,0 +1,118 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: async-grpc-compatible
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Samuel Williams
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: async-grpc
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.10'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.10'
26
+ - !ruby/object:Gem::Dependency
27
+ name: async-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.100'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.100'
40
+ - !ruby/object:Gem::Dependency
41
+ name: grpc
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: io-endpoint
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.19'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.19'
68
+ - !ruby/object:Gem::Dependency
69
+ name: protocol-grpc
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '0.17'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.17'
82
+ executables: []
83
+ extensions: []
84
+ extra_rdoc_files: []
85
+ files:
86
+ - lib/async/grpc/compatible.rb
87
+ - lib/async/grpc/compatible/channel_credentials.rb
88
+ - lib/async/grpc/compatible/client_stub.rb
89
+ - lib/async/grpc/compatible/gapic.rb
90
+ - lib/async/grpc/compatible/operation.rb
91
+ - lib/async/grpc/compatible/version.rb
92
+ - license.md
93
+ - readme.md
94
+ - releases.md
95
+ homepage: https://github.com/socketry/async-grpc-compatible
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ documentation_uri: https://socketry.github.io/async-grpc-compatible/
100
+ source_code_uri: https://github.com/socketry/async-grpc-compatible.git
101
+ rdoc_options: []
102
+ require_paths:
103
+ - lib
104
+ required_ruby_version: !ruby/object:Gem::Requirement
105
+ requirements:
106
+ - - ">="
107
+ - !ruby/object:Gem::Version
108
+ version: '3.3'
109
+ required_rubygems_version: !ruby/object:Gem::Requirement
110
+ requirements:
111
+ - - ">="
112
+ - !ruby/object:Gem::Version
113
+ version: '0'
114
+ requirements: []
115
+ rubygems_version: 4.0.16
116
+ specification_version: 4
117
+ summary: grpc-ruby compatible client interfaces using Async::GRPC.
118
+ test_files: []