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 +7 -0
- data/lib/async/grpc/compatible/channel_credentials.rb +39 -0
- data/lib/async/grpc/compatible/client_stub.rb +494 -0
- data/lib/async/grpc/compatible/gapic.rb +55 -0
- data/lib/async/grpc/compatible/operation.rb +95 -0
- data/lib/async/grpc/compatible/version.rb +15 -0
- data/lib/async/grpc/compatible.rb +15 -0
- data/license.md +21 -0
- data/readme.md +189 -0
- data/releases.md +17 -0
- metadata +118 -0
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
|
+
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: []
|