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.
@@ -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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ # @namespace
7
+ module Async
8
+ # @namespace
9
+ module GRPC
10
+ VERSION = "0.1.0"
11
+ end
12
+ 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.