async-grpc 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/code.md +0 -0
- data/context/getting-started.md +174 -0
- data/context/index.yaml +12 -0
- data/design.md +1121 -0
- data/lib/async/grpc/client.rb +351 -0
- data/lib/async/grpc/dispatcher_middleware.rb +113 -0
- data/lib/async/grpc/service.rb +93 -0
- data/lib/async/grpc/stub.rb +89 -0
- data/lib/async/grpc/version.rb +12 -0
- data/lib/async/grpc.rb +15 -0
- data/license.md +21 -0
- data/readme.md +52 -0
- data/releases.md +3 -0
- data/spanner_integration.md +309 -0
- metadata +81 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async"
|
|
7
|
+
require "async/http/client"
|
|
8
|
+
require "async/http/endpoint"
|
|
9
|
+
|
|
10
|
+
require "protocol/http"
|
|
11
|
+
require "protocol/grpc"
|
|
12
|
+
require "protocol/grpc/interface"
|
|
13
|
+
require "protocol/grpc/methods"
|
|
14
|
+
require "protocol/grpc/body/readable_body"
|
|
15
|
+
require "protocol/grpc/body/writable_body"
|
|
16
|
+
require "protocol/grpc/metadata"
|
|
17
|
+
require "protocol/grpc/error"
|
|
18
|
+
require_relative "stub"
|
|
19
|
+
|
|
20
|
+
module Async
|
|
21
|
+
module GRPC
|
|
22
|
+
# Represents a client for making gRPC calls over HTTP/2.
|
|
23
|
+
class Client < Protocol::HTTP::Middleware
|
|
24
|
+
ENDPOINT = nil
|
|
25
|
+
|
|
26
|
+
# Connect to the given endpoint, returning the HTTP client.
|
|
27
|
+
# @parameter endpoint [Async::HTTP::Endpoint] used to connect to the remote system.
|
|
28
|
+
# @returns [Async::HTTP::Client] the HTTP client.
|
|
29
|
+
def self.connect(endpoint)
|
|
30
|
+
HTTP::Client.new(endpoint)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Create a new client for the given endpoint.
|
|
34
|
+
# @parameter endpoint [Async::HTTP::Endpoint, String] The endpoint to connect to
|
|
35
|
+
# @parameter headers [Protocol::HTTP::Headers] Default headers to include with requests
|
|
36
|
+
# @yields {|client| ...} Optional block - client will be closed after block execution
|
|
37
|
+
# @returns [Client] The client instance
|
|
38
|
+
def self.open(endpoint = self::ENDPOINT, headers: Protocol::HTTP::Headers.new, **options)
|
|
39
|
+
endpoint = Async::HTTP::Endpoint.parse(endpoint) if endpoint.is_a?(String)
|
|
40
|
+
|
|
41
|
+
client = connect(endpoint)
|
|
42
|
+
|
|
43
|
+
grpc_client = new(client, headers: headers, **options)
|
|
44
|
+
|
|
45
|
+
return grpc_client unless block_given?
|
|
46
|
+
|
|
47
|
+
Sync do
|
|
48
|
+
yield grpc_client
|
|
49
|
+
ensure
|
|
50
|
+
grpc_client.close
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Create a new client with merged headers from a parent client.
|
|
55
|
+
# @parameter parent [Client] The parent client to inherit headers from
|
|
56
|
+
# @parameter headers [Hash] Additional headers to merge
|
|
57
|
+
# @returns [Client] A new client instance with merged headers
|
|
58
|
+
def self.with(parent, headers: {})
|
|
59
|
+
merged_headers = parent.headers.merge(headers)
|
|
60
|
+
|
|
61
|
+
new(parent.delegate, headers: merged_headers)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Initialize a new gRPC client.
|
|
65
|
+
# @parameter delegate [Async::HTTP::Client] The HTTP client that will handle requests
|
|
66
|
+
# @parameter headers [Protocol::HTTP::Headers] The default headers that will be supplied with requests
|
|
67
|
+
def initialize(delegate, headers: Protocol::HTTP::Headers.new)
|
|
68
|
+
super(delegate)
|
|
69
|
+
|
|
70
|
+
@headers = headers
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @attribute [Protocol::HTTP::Headers] The default headers for requests.
|
|
74
|
+
attr_reader :headers
|
|
75
|
+
|
|
76
|
+
# Get a string representation of the client.
|
|
77
|
+
# @returns [String] A string representation including headers
|
|
78
|
+
def inspect
|
|
79
|
+
"\#<#{self.class} #{@headers.inspect}>"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Get a string representation of the client.
|
|
83
|
+
# @returns [String] A string representation of the client class
|
|
84
|
+
def to_s
|
|
85
|
+
"\#<#{self.class}>"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Create a stub for the given interface.
|
|
89
|
+
# @parameter interface_class [Class] Interface class (subclass of Protocol::GRPC::Interface)
|
|
90
|
+
# @parameter service_name [String] Service name (e.g., "hello.Greeter")
|
|
91
|
+
# @returns [Async::GRPC::Stub] Stub object with methods for each RPC
|
|
92
|
+
def stub(interface_class, service_name)
|
|
93
|
+
interface = interface_class.new(service_name)
|
|
94
|
+
Stub.new(self, interface)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Call the underlying HTTP client with merged headers.
|
|
98
|
+
# @parameter request [Protocol::HTTP::Request] The HTTP request
|
|
99
|
+
# @returns [Protocol::HTTP::Response] The HTTP response
|
|
100
|
+
def call(request)
|
|
101
|
+
request.headers = @headers.merge(request.headers)
|
|
102
|
+
|
|
103
|
+
super
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Make a gRPC call.
|
|
107
|
+
# @parameter service [Protocol::GRPC::Interface] Interface definition
|
|
108
|
+
# @parameter method [Symbol, String] Method name
|
|
109
|
+
# @parameter request [Object | Nil] Request message (`Nil` for streaming)
|
|
110
|
+
# @parameter metadata [Hash] Custom metadata headers
|
|
111
|
+
# @parameter timeout [Numeric | Nil] Optional timeout in seconds
|
|
112
|
+
# @parameter encoding [String | Nil] Optional compression encoding
|
|
113
|
+
# @yields {|input, output| ...} Block for streaming calls
|
|
114
|
+
# @returns [Object | Protocol::GRPC::Body::ReadableBody] Response message or readable body for streaming
|
|
115
|
+
# @raises [ArgumentError] If method is unknown or streaming type is invalid
|
|
116
|
+
# @raises [Protocol::GRPC::Error] If the gRPC call fails
|
|
117
|
+
def invoke(service, method, request = nil, metadata: {}, timeout: nil, encoding: nil, &block)
|
|
118
|
+
rpc = service.class.lookup_rpc(method)
|
|
119
|
+
raise ArgumentError, "Unknown method: #{method}" unless rpc
|
|
120
|
+
|
|
121
|
+
path = service.path(method)
|
|
122
|
+
headers = Protocol::GRPC::Methods.build_headers(
|
|
123
|
+
metadata: metadata,
|
|
124
|
+
timeout: timeout,
|
|
125
|
+
content_type: "application/grpc+proto"
|
|
126
|
+
)
|
|
127
|
+
headers["grpc-encoding"] = encoding if encoding
|
|
128
|
+
|
|
129
|
+
streaming = rpc.streaming
|
|
130
|
+
request_class = rpc.request_class
|
|
131
|
+
response_class = rpc.response_class
|
|
132
|
+
|
|
133
|
+
case streaming
|
|
134
|
+
when :unary
|
|
135
|
+
unary_call(path, headers, request, request_class, response_class, encoding)
|
|
136
|
+
when :server_streaming
|
|
137
|
+
server_streaming_call(path, headers, request, request_class, response_class, encoding, &block)
|
|
138
|
+
when :client_streaming
|
|
139
|
+
client_streaming_call(path, headers, request_class, response_class, encoding, &block)
|
|
140
|
+
when :bidirectional
|
|
141
|
+
bidirectional_call(path, headers, request_class, response_class, encoding, &block)
|
|
142
|
+
else
|
|
143
|
+
raise ArgumentError, "Unknown streaming type: #{streaming}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
protected
|
|
148
|
+
|
|
149
|
+
# Make a unary gRPC call.
|
|
150
|
+
# @parameter path [String] The gRPC path
|
|
151
|
+
# @parameter headers [Protocol::HTTP::Headers] Request headers
|
|
152
|
+
# @parameter request_message [Object] Request message
|
|
153
|
+
# @parameter request_class [Class] Request message class
|
|
154
|
+
# @parameter response_class [Class] Response message class
|
|
155
|
+
# @parameter encoding [String | Nil] Compression encoding
|
|
156
|
+
# @returns [Object] Response message
|
|
157
|
+
# @raises [Protocol::GRPC::Error] If the gRPC call fails
|
|
158
|
+
def unary_call(path, headers, request_message, request_class, response_class, encoding)
|
|
159
|
+
body = Protocol::GRPC::Body::WritableBody.new(encoding: encoding, message_class: request_class)
|
|
160
|
+
body.write(request_message)
|
|
161
|
+
body.close_write
|
|
162
|
+
|
|
163
|
+
http_request = Protocol::HTTP::Request["POST", path, headers, body]
|
|
164
|
+
response = call(http_request)
|
|
165
|
+
|
|
166
|
+
begin
|
|
167
|
+
# Read body first - trailers are only available after body is consumed
|
|
168
|
+
response_encoding = response.headers["grpc-encoding"]
|
|
169
|
+
readable_body = Protocol::GRPC::Body::ReadableBody.new(
|
|
170
|
+
response.body,
|
|
171
|
+
message_class: response_class,
|
|
172
|
+
encoding: response_encoding
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
message = readable_body.read
|
|
176
|
+
readable_body.close
|
|
177
|
+
|
|
178
|
+
# Check status after reading body (trailers are now available)
|
|
179
|
+
check_status!(response)
|
|
180
|
+
|
|
181
|
+
message
|
|
182
|
+
ensure
|
|
183
|
+
response.close
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Make a server streaming gRPC call.
|
|
188
|
+
# @parameter path [String] The gRPC path
|
|
189
|
+
# @parameter headers [Protocol::HTTP::Headers] Request headers
|
|
190
|
+
# @parameter request_message [Object] Request message
|
|
191
|
+
# @parameter request_class [Class] Request message class
|
|
192
|
+
# @parameter response_class [Class] Response message class
|
|
193
|
+
# @parameter encoding [String | Nil] Compression encoding
|
|
194
|
+
# @yields {|message| ...} Block to process each message in the stream
|
|
195
|
+
# @returns [Protocol::GRPC::Body::ReadableBody] Readable body for streaming messages
|
|
196
|
+
# @raises [Protocol::GRPC::Error] If the gRPC call fails
|
|
197
|
+
def server_streaming_call(path, headers, request_message, request_class, response_class, encoding, &block)
|
|
198
|
+
body = Protocol::GRPC::Body::WritableBody.new(encoding: encoding, message_class: request_class)
|
|
199
|
+
body.write(request_message)
|
|
200
|
+
body.close_write
|
|
201
|
+
|
|
202
|
+
http_request = Protocol::HTTP::Request["POST", path, headers, body]
|
|
203
|
+
response = call(http_request)
|
|
204
|
+
|
|
205
|
+
begin
|
|
206
|
+
# Set gRPC policy BEFORE reading body so trailers are processed correctly:
|
|
207
|
+
unless response.headers.policy == Protocol::GRPC::HEADER_POLICY
|
|
208
|
+
response.headers.policy = Protocol::GRPC::HEADER_POLICY
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Read body first - trailers are only available after body is consumed:
|
|
212
|
+
response_encoding = response.headers["grpc-encoding"]
|
|
213
|
+
readable_body = Protocol::GRPC::Body::ReadableBody.new(
|
|
214
|
+
response.body,
|
|
215
|
+
message_class: response_class,
|
|
216
|
+
encoding: response_encoding
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return readable_body unless block_given?
|
|
220
|
+
|
|
221
|
+
begin
|
|
222
|
+
readable_body.each(&block)
|
|
223
|
+
ensure
|
|
224
|
+
readable_body.close
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Check status after reading all body chunks (trailers are now available):
|
|
228
|
+
check_status!(response)
|
|
229
|
+
|
|
230
|
+
readable_body
|
|
231
|
+
rescue StandardError
|
|
232
|
+
response.close
|
|
233
|
+
raise
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Make a client streaming gRPC call.
|
|
238
|
+
# @parameter path [String] The gRPC path
|
|
239
|
+
# @parameter headers [Protocol::HTTP::Headers] Request headers
|
|
240
|
+
# @parameter request_class [Class] Request message class
|
|
241
|
+
# @parameter response_class [Class] Response message class
|
|
242
|
+
# @parameter encoding [String | Nil] Compression encoding
|
|
243
|
+
# @yields {|output| ...} Block to write messages to the stream
|
|
244
|
+
# @returns [Object] Response message
|
|
245
|
+
# @raises [Protocol::GRPC::Error] If the gRPC call fails
|
|
246
|
+
def client_streaming_call(path, headers, request_class, response_class, encoding, &block)
|
|
247
|
+
body = Protocol::GRPC::Body::WritableBody.new(encoding: encoding, message_class: request_class)
|
|
248
|
+
|
|
249
|
+
http_request = Protocol::HTTP::Request["POST", path, headers, body]
|
|
250
|
+
|
|
251
|
+
block.call(body) if block_given?
|
|
252
|
+
body.close_write unless body.closed?
|
|
253
|
+
|
|
254
|
+
response = call(http_request)
|
|
255
|
+
|
|
256
|
+
begin
|
|
257
|
+
# Read body first - trailers are only available after body is consumed:
|
|
258
|
+
response_encoding = response.headers["grpc-encoding"]
|
|
259
|
+
readable_body = Protocol::GRPC::Body::ReadableBody.new(
|
|
260
|
+
response.body,
|
|
261
|
+
message_class: response_class,
|
|
262
|
+
encoding: response_encoding
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
message = readable_body.read
|
|
266
|
+
readable_body.close
|
|
267
|
+
|
|
268
|
+
# Check status after reading body (trailers are now available):
|
|
269
|
+
check_status!(response)
|
|
270
|
+
|
|
271
|
+
message
|
|
272
|
+
ensure
|
|
273
|
+
response.close
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Make a bidirectional streaming gRPC call.
|
|
278
|
+
# @parameter path [String] The gRPC path
|
|
279
|
+
# @parameter headers [Protocol::HTTP::Headers] Request headers
|
|
280
|
+
# @parameter request_class [Class] Request message class
|
|
281
|
+
# @parameter response_class [Class] Response message class
|
|
282
|
+
# @parameter encoding [String | Nil] Compression encoding
|
|
283
|
+
# @yields {|input, output| ...} Block to handle bidirectional streaming
|
|
284
|
+
# @returns [Protocol::GRPC::Body::ReadableBody] Readable body for streaming messages
|
|
285
|
+
# @raises [Protocol::GRPC::Error] If the gRPC call fails
|
|
286
|
+
def bidirectional_call(path, headers, request_class, response_class, encoding, &block)
|
|
287
|
+
body = Protocol::GRPC::Body::WritableBody.new(
|
|
288
|
+
encoding: encoding,
|
|
289
|
+
message_class: request_class
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
http_request = Protocol::HTTP::Request["POST", path, headers, body]
|
|
293
|
+
response = call(http_request)
|
|
294
|
+
|
|
295
|
+
begin
|
|
296
|
+
# Read body first - trailers are only available after body is consumed:
|
|
297
|
+
response_encoding = response.headers["grpc-encoding"]
|
|
298
|
+
readable_body = Protocol::GRPC::Body::ReadableBody.new(
|
|
299
|
+
response.body,
|
|
300
|
+
message_class: response_class,
|
|
301
|
+
encoding: response_encoding
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return readable_body unless block_given?
|
|
305
|
+
|
|
306
|
+
begin
|
|
307
|
+
block.call(readable_body, body)
|
|
308
|
+
body.close_write unless body.closed?
|
|
309
|
+
|
|
310
|
+
# Consume all response chunks to ensure trailers are available:
|
|
311
|
+
readable_body.each{|_|}
|
|
312
|
+
ensure
|
|
313
|
+
readable_body.close
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Check status after reading all body chunks (trailers are now available):
|
|
317
|
+
check_status!(response)
|
|
318
|
+
|
|
319
|
+
readable_body
|
|
320
|
+
rescue StandardError
|
|
321
|
+
response.close
|
|
322
|
+
raise
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Check gRPC status and raise appropriate error if not OK.
|
|
327
|
+
# @parameter response [Protocol::HTTP::Response]
|
|
328
|
+
# @raises [Protocol::GRPC::Error] If status is not OK
|
|
329
|
+
def check_status!(response)
|
|
330
|
+
# Policy should already be set before calling this method:
|
|
331
|
+
# But ensure it's set just in case
|
|
332
|
+
unless response.headers.policy == Protocol::GRPC::HEADER_POLICY
|
|
333
|
+
response.headers.policy = Protocol::GRPC::HEADER_POLICY
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
status = Protocol::GRPC::Metadata.extract_status(response.headers)
|
|
337
|
+
|
|
338
|
+
# If status is UNKNOWN (not found), default to OK:
|
|
339
|
+
# This handles cases where trailers aren't available or status wasn't set
|
|
340
|
+
status = Protocol::GRPC::Status::OK if status == Protocol::GRPC::Status::UNKNOWN
|
|
341
|
+
|
|
342
|
+
return if status == Protocol::GRPC::Status::OK
|
|
343
|
+
|
|
344
|
+
message = Protocol::GRPC::Metadata.extract_message(response.headers)
|
|
345
|
+
metadata = Protocol::GRPC::Methods.extract_metadata(response.headers)
|
|
346
|
+
|
|
347
|
+
raise Protocol::GRPC::Error.for(status, message, metadata: metadata)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "protocol/grpc/middleware"
|
|
7
|
+
require "protocol/grpc/methods"
|
|
8
|
+
require "protocol/grpc/call"
|
|
9
|
+
require "protocol/grpc/body/readable_body"
|
|
10
|
+
require "protocol/grpc/body/writable_body"
|
|
11
|
+
require "protocol/grpc/metadata"
|
|
12
|
+
require "protocol/grpc/error"
|
|
13
|
+
require "protocol/grpc/status"
|
|
14
|
+
|
|
15
|
+
module Async
|
|
16
|
+
module GRPC
|
|
17
|
+
# Represents middleware that dispatches gRPC requests to registered services.
|
|
18
|
+
# Handles routing based on service name from the request path.
|
|
19
|
+
#
|
|
20
|
+
# @example Registering services:
|
|
21
|
+
# dispatcher = DispatcherMiddleware.new
|
|
22
|
+
# dispatcher.register("hello.Greeter", GreeterService.new(GreeterInterface, "hello.Greeter"))
|
|
23
|
+
# dispatcher.register("world.Greeter", WorldService.new(WorldInterface, "world.Greeter"))
|
|
24
|
+
#
|
|
25
|
+
# server = Async::HTTP::Server.for(endpoint, dispatcher)
|
|
26
|
+
class DispatcherMiddleware < Protocol::GRPC::Middleware
|
|
27
|
+
# Initialize the dispatcher.
|
|
28
|
+
# @parameter app [#call | Nil] The next middleware in the chain
|
|
29
|
+
# @parameter services [Hash] Optional initial services hash (service_name => service_instance)
|
|
30
|
+
def initialize(app = nil, services: {})
|
|
31
|
+
super(app)
|
|
32
|
+
@services = services
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Register a service.
|
|
36
|
+
# @parameter service_name [String] Service name (e.g., "hello.Greeter")
|
|
37
|
+
# @parameter service [Async::GRPC::Service] Service instance
|
|
38
|
+
def register(service_name, service)
|
|
39
|
+
@services[service_name] = service
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
protected
|
|
43
|
+
|
|
44
|
+
# Dispatch the request to the appropriate service.
|
|
45
|
+
# @parameter request [Protocol::HTTP::Request] The HTTP request
|
|
46
|
+
# @returns [Protocol::HTTP::Response] The HTTP response
|
|
47
|
+
# @raises [Protocol::GRPC::Error] If service or method is not found
|
|
48
|
+
def dispatch(request)
|
|
49
|
+
# Parse service and method from path:
|
|
50
|
+
service_name, method_name = Protocol::GRPC::Methods.parse_path(request.path)
|
|
51
|
+
|
|
52
|
+
# Find service:
|
|
53
|
+
service = @services[service_name]
|
|
54
|
+
unless service
|
|
55
|
+
raise Protocol::GRPC::Error.new(Protocol::GRPC::Status::UNIMPLEMENTED, "Service not found: #{service_name}")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Verify service name matches:
|
|
59
|
+
unless service_name == service.service_name
|
|
60
|
+
raise Protocol::GRPC::Error.new(Protocol::GRPC::Status::UNIMPLEMENTED, "Service name mismatch: expected #{service.service_name}, got #{service_name}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Get RPC descriptions from the service:
|
|
64
|
+
rpc_descriptor = service.rpc_descriptions[method_name]
|
|
65
|
+
unless rpc_descriptor
|
|
66
|
+
raise Protocol::GRPC::Error.new(Protocol::GRPC::Status::UNIMPLEMENTED, "Method not found: #{method_name}")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
handler_method = rpc_descriptor[:method]
|
|
70
|
+
request_class = rpc_descriptor[:request_class]
|
|
71
|
+
response_class = rpc_descriptor[:response_class]
|
|
72
|
+
|
|
73
|
+
# Verify handler method exists:
|
|
74
|
+
unless service.respond_to?(handler_method, true)
|
|
75
|
+
raise Protocol::GRPC::Error.new(Protocol::GRPC::Status::UNIMPLEMENTED, "Handler method not implemented: #{handler_method}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Create protocol-level objects for gRPC handling:
|
|
79
|
+
encoding = request.headers["grpc-encoding"]
|
|
80
|
+
input = Protocol::GRPC::Body::ReadableBody.new(request.body, message_class: request_class, encoding: encoding)
|
|
81
|
+
output = Protocol::GRPC::Body::WritableBody.new(message_class: response_class, encoding: encoding)
|
|
82
|
+
|
|
83
|
+
# Create call context:
|
|
84
|
+
response_headers = Protocol::HTTP::Headers.new([], nil, policy: Protocol::GRPC::HEADER_POLICY)
|
|
85
|
+
response_headers["content-type"] = "application/grpc+proto"
|
|
86
|
+
response_headers["grpc-encoding"] = encoding if encoding
|
|
87
|
+
|
|
88
|
+
# Parse deadline from timeout header:
|
|
89
|
+
timeout_value = request.headers["grpc-timeout"]
|
|
90
|
+
deadline = if timeout_value
|
|
91
|
+
timeout_seconds = Protocol::GRPC::Methods.parse_timeout(timeout_value)
|
|
92
|
+
require "async/deadline"
|
|
93
|
+
Async::Deadline.start(timeout_seconds) if timeout_seconds
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
call = Protocol::GRPC::Call.new(request, deadline: deadline)
|
|
97
|
+
|
|
98
|
+
# Call the handler method on the service:
|
|
99
|
+
service.send(handler_method, input, output, call)
|
|
100
|
+
|
|
101
|
+
# Close output stream:
|
|
102
|
+
output.close_write unless output.closed?
|
|
103
|
+
|
|
104
|
+
# Mark trailers and add status:
|
|
105
|
+
response_headers.trailer!
|
|
106
|
+
Protocol::GRPC::Metadata.add_status_trailer!(response_headers, status: Protocol::GRPC::Status::OK)
|
|
107
|
+
|
|
108
|
+
Protocol::HTTP::Response[200, response_headers, output]
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "protocol/grpc/interface"
|
|
7
|
+
|
|
8
|
+
module Async
|
|
9
|
+
module GRPC
|
|
10
|
+
# Represents a concrete service implementation that uses an Interface.
|
|
11
|
+
# Subclass this and implement the RPC methods defined in the interface.
|
|
12
|
+
# Services are registered with DispatcherMiddleware for routing.
|
|
13
|
+
#
|
|
14
|
+
# @example Example service implementation:
|
|
15
|
+
# class GreeterInterface < Protocol::GRPC::Interface
|
|
16
|
+
# rpc :SayHello, request_class: Hello::HelloRequest, response_class: Hello::HelloReply
|
|
17
|
+
# # Optional: explicit method name for edge cases
|
|
18
|
+
# rpc :XMLParser, request_class: Hello::ParseRequest, response_class: Hello::ParseReply,
|
|
19
|
+
# method: :xml_parser # Explicit method name (otherwise would be :xmlparser)
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# class GreeterService < Async::GRPC::Service
|
|
23
|
+
# def say_hello(input, output, call)
|
|
24
|
+
# request = input.read
|
|
25
|
+
# reply = Hello::HelloReply.new(message: "Hello, #{request.name}!")
|
|
26
|
+
# output.write(reply)
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# def xml_parser(input, output, call)
|
|
30
|
+
# # Implementation using explicit method name
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# # Register with dispatcher:
|
|
35
|
+
# dispatcher = DispatcherMiddleware.new
|
|
36
|
+
# dispatcher.register("hello.Greeter", GreeterService.new(GreeterInterface, "hello.Greeter"))
|
|
37
|
+
# server = Async::HTTP::Server.for(endpoint, dispatcher)
|
|
38
|
+
class Service
|
|
39
|
+
# Initialize a new service instance.
|
|
40
|
+
# @parameter interface_class [Class] The interface class (subclass of Protocol::GRPC::Interface)
|
|
41
|
+
# @parameter service_name [String] The service name (e.g., "hello.Greeter")
|
|
42
|
+
def initialize(interface_class, service_name)
|
|
43
|
+
@interface_class = interface_class
|
|
44
|
+
@service_name = service_name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @attribute [Class] The interface class.
|
|
48
|
+
attr_reader :interface_class
|
|
49
|
+
|
|
50
|
+
# @attribute [String] The service name.
|
|
51
|
+
attr_reader :service_name
|
|
52
|
+
|
|
53
|
+
# Get RPC descriptions from the interface class.
|
|
54
|
+
# Converts Interface RPC definitions (PascalCase) to rpc_descriptions format.
|
|
55
|
+
# Maps gRPC method names (PascalCase) to Ruby method names (snake_case).
|
|
56
|
+
# @returns [Hash] RPC descriptions hash keyed by PascalCase method name
|
|
57
|
+
def rpc_descriptions
|
|
58
|
+
descriptions = {}
|
|
59
|
+
|
|
60
|
+
@interface_class.rpcs.each do |pascal_case_name, rpc|
|
|
61
|
+
# Use explicit method name if provided, otherwise convert PascalCase to snake_case:
|
|
62
|
+
ruby_method_name = if rpc.method
|
|
63
|
+
rpc.method
|
|
64
|
+
else
|
|
65
|
+
snake_case_name = pascal_case_to_snake_case(pascal_case_name.to_s)
|
|
66
|
+
snake_case_name.to_sym
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
descriptions[pascal_case_name.to_s] = {
|
|
70
|
+
method: ruby_method_name,
|
|
71
|
+
request_class: rpc.request_class,
|
|
72
|
+
response_class: rpc.response_class,
|
|
73
|
+
streaming: rpc.streaming
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
descriptions
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Convert PascalCase to snake_case.
|
|
83
|
+
# @parameter pascal_case [String] PascalCase string (e.g., "SayHello")
|
|
84
|
+
# @returns [String] snake_case string (e.g., "say_hello")
|
|
85
|
+
def pascal_case_to_snake_case(pascal_case)
|
|
86
|
+
pascal_case
|
|
87
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') # Insert underscore before capital letters followed by lowercase
|
|
88
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2') # Insert underscore between lowercase/digit and uppercase
|
|
89
|
+
.downcase
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "protocol/grpc/interface"
|
|
7
|
+
|
|
8
|
+
module Async
|
|
9
|
+
module GRPC
|
|
10
|
+
# Represents a client stub that provides method-based access to RPC calls.
|
|
11
|
+
# Created by calling {Client#stub}.
|
|
12
|
+
class Stub
|
|
13
|
+
# Initialize a new stub instance.
|
|
14
|
+
# @parameter client [Async::GRPC::Client] The gRPC client
|
|
15
|
+
# @parameter interface [Protocol::GRPC::Interface] The interface instance
|
|
16
|
+
def initialize(client, interface)
|
|
17
|
+
@client = client
|
|
18
|
+
@interface = interface
|
|
19
|
+
@interface_class = interface.class
|
|
20
|
+
# Cache RPCs indexed by snake_case method name (default)
|
|
21
|
+
@rpcs_by_method = {}
|
|
22
|
+
|
|
23
|
+
@interface_class.rpcs.each do |pascal_case_name, rpc|
|
|
24
|
+
# rpc.method is always set (either explicit or auto-converted from PascalCase)
|
|
25
|
+
snake_case_method = rpc.method
|
|
26
|
+
|
|
27
|
+
# Index by snake_case method name, storing RPC and PascalCase name for path building
|
|
28
|
+
@rpcs_by_method[snake_case_method] = [rpc, pascal_case_name]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @attribute [Protocol::GRPC::Interface] The interface instance.
|
|
33
|
+
attr_reader :interface
|
|
34
|
+
|
|
35
|
+
# Dynamically handle method calls for RPC methods.
|
|
36
|
+
# Uses snake_case method names (Ruby convention).
|
|
37
|
+
# @parameter method_name [Symbol] The method name to call (snake_case)
|
|
38
|
+
# @parameter args [Array] Positional arguments (first is the request message)
|
|
39
|
+
# @parameter options [Hash] Keyword arguments (metadata, timeout, encoding)
|
|
40
|
+
# @yields {|input, output| ...} Block for streaming calls
|
|
41
|
+
# @returns [Object | Protocol::GRPC::Body::ReadableBody] Response message or readable body
|
|
42
|
+
# @raises [NoMethodError] If the method is not found
|
|
43
|
+
def method_missing(method_name, *args, **options, &block)
|
|
44
|
+
rpc, interface_method_name = lookup_rpc(method_name)
|
|
45
|
+
|
|
46
|
+
if rpc
|
|
47
|
+
# Extract request from args (first positional argument):
|
|
48
|
+
request = args.first
|
|
49
|
+
|
|
50
|
+
# Extract metadata, timeout, encoding from options:
|
|
51
|
+
metadata = options.delete(:metadata) || {}
|
|
52
|
+
timeout = options.delete(:timeout)
|
|
53
|
+
encoding = options.delete(:encoding)
|
|
54
|
+
|
|
55
|
+
# Delegate to client.invoke with PascalCase method name (for interface lookup):
|
|
56
|
+
@client.invoke(@interface, interface_method_name, request, metadata: metadata, timeout: timeout, encoding: encoding,
|
|
57
|
+
&block)
|
|
58
|
+
else
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Check if the stub responds to the given method.
|
|
64
|
+
# @parameter method_name [Symbol] The method name to check
|
|
65
|
+
# @parameter include_private [Boolean] Whether to include private methods
|
|
66
|
+
# @returns [Boolean] `true` if the method exists, `false` otherwise
|
|
67
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
68
|
+
rpc, _ = lookup_rpc(method_name)
|
|
69
|
+
return true if rpc
|
|
70
|
+
|
|
71
|
+
super
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
# Look up RPC definition for a method name.
|
|
77
|
+
# @parameter method_name [Symbol] The method name to look up (snake_case)
|
|
78
|
+
# @returns [Array(Protocol::GRPC::RPC, Symbol) | Array(Nil, Nil)] RPC definition and PascalCase method name, or nil if not found
|
|
79
|
+
def lookup_rpc(method_name)
|
|
80
|
+
if @rpcs_by_method.key?(method_name)
|
|
81
|
+
rpc, pascal_case_name = @rpcs_by_method[method_name]
|
|
82
|
+
return [rpc, pascal_case_name]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
[nil, nil]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/async/grpc.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "grpc/version"
|
|
7
|
+
require_relative "grpc/client"
|
|
8
|
+
require_relative "grpc/service"
|
|
9
|
+
require_relative "grpc/stub"
|
|
10
|
+
require_relative "grpc/dispatcher_middleware"
|
|
11
|
+
|
|
12
|
+
module Async
|
|
13
|
+
module GRPC
|
|
14
|
+
end
|
|
15
|
+
end
|
data/license.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright, 2025, 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.
|