tonic-rails 0.1.0-aarch64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0b055719a1717245ea8a65525f12c18389f8209de1a17a1f474c35d76dff2a20
4
+ data.tar.gz: 1021a911c9ea4e1d2f3c86e8ce1d6b643d2d209807bda77782c87f827fcf1900
5
+ SHA512:
6
+ metadata.gz: 74deac3a8dba345a0a690571cdb56fc387b07de8643bef429f364b4ae9cffbb1e401ae0939a19cb33bc3558a34c8ba74434cb3068c4c71200c43a51519368473
7
+ data.tar.gz: 05abf283904c216b82bb94265cef50ec79886f02c0e585df343148fc7f7feaf8751309a671fe57a60255c198ef5d44fa1b0b2e0a26d5eae259d222f42162e144
data/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the
5
+ project follows [Semantic Versioning](https://semver.org/). Until 1.0,
6
+ minor versions may contain breaking changes.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ First public release.
13
+
14
+ ### Serving
15
+
16
+ - gRPC server for Rails built on tonic 0.14 (HTTP/2, TLS via rustls,
17
+ keepalive, flow control), with Ruby handlers running on a pool of
18
+ dispatch threads inside the Rails executor.
19
+ - Unary, server-streaming, client-streaming and bidirectional RPCs, with
20
+ backpressure in both directions and incremental streaming.
21
+ - Handlers declared with `rpc_service` (read from the protobuf descriptor
22
+ pool) or individual `rpc` calls; requests as Hashes or, with
23
+ `request_format :message`, protobuf messages; responses as either.
24
+ - Routes registered at runtime: no service definitions are compiled into
25
+ the native extension.
26
+ - Built-in `grpc.health.v1` (per service, flips to NOT_SERVING on
27
+ shutdown) and opt-in `grpc.reflection.v1` generated from loaded
28
+ descriptors.
29
+ - Interceptors, request metadata, client deadlines (`deadline_ms`,
30
+ `deadline_exceeded?`) and shutdown awareness (`cancelled?`).
31
+ - `Tonic::Rails::Testing.call` for in-process handler tests.
32
+ - Railtie: `config.tonic_rails.*` settings, Rails logger and error
33
+ reporter, code reloading support, rake tasks `tonic_rails:serve`,
34
+ `tonic_rails:routes` and `tonic_rails:compile_protos`.
35
+
36
+ ### Production behaviour
37
+
38
+ - Graceful shutdown on SIGTERM/SIGINT: health flips to NOT_SERVING, new
39
+ connections are refused, in-flight calls drain for `drain_timeout`.
40
+ - Limits: `max_message_size` (also caps gzip-decompressed size),
41
+ `max_connections`, `concurrency_limit_per_connection`, request metadata
42
+ size, and `dispatch_queue_size` load shedding (`RESOURCE_EXHAUSTED`).
43
+ - TLS and mutual TLS (`tls_client_ca_path`). Invalid TLS configuration
44
+ stops startup; the server never falls back to plaintext.
45
+ - Unexpected exceptions (in handlers or interceptors) reach clients only
46
+ as `INTERNAL: internal error`; details are logged and reported.
47
+ - `grpc-message` is percent-encoded per the gRPC spec.
48
+ - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
49
+ macOS (x86_64, arm64), Ruby 3.3 to 4.0.
50
+
51
+ [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...HEAD
52
+ [0.1.0]: https://github.com/CodingAnarchy/tonic-rails/releases/tag/v0.1.0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Tanous
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,391 @@
1
+ # tonic-rails
2
+
3
+ [![CI](https://github.com/CodingAnarchy/tonic-rails/actions/workflows/ci.yml/badge.svg)](https://github.com/CodingAnarchy/tonic-rails/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/tonic-rails.svg)](https://rubygems.org/gems/tonic-rails)
5
+
6
+ Serve gRPC from your Rails app, with the transport handled by
7
+ [tonic], Rust's production gRPC stack.
8
+
9
+ Think of it like Puma for gRPC. Puma handles sockets and HTTP so your
10
+ controllers only see requests. tonic-rails does the same for gRPC:
11
+ HTTP/2, TLS, flow control, keepalives, health checks and reflection all
12
+ run in Rust, outside the GVL. Your handlers are plain Ruby classes that
13
+ receive protobuf messages and return them, inside the Rails executor,
14
+ with your models, caches and error reporting all available.
15
+
16
+ ```ruby
17
+ class ItemService
18
+ include Tonic::Rails::Base
19
+ rpc_service "demo.v1.ItemService"
20
+ request_format :message
21
+
22
+ def get_item(request)
23
+ item = Item.find_by(id: request.id)
24
+ raise Tonic::Rails::RpcError.new("item not found", code: :not_found) unless item
25
+
26
+ Demo::V1::Item.new(id: item.id, name: item.name)
27
+ end
28
+ end
29
+ ```
30
+
31
+ ## Why tonic-rails
32
+
33
+ - **Fast where it counts.** Transport overhead is small: an empty RPC
34
+ runs at roughly 75–80% of a pure-Rust tonic server's throughput. I/O-bound
35
+ handlers scale with dispatch threads because Ruby releases the GVL
36
+ while waiting. See [PERF.md](PERF.md).
37
+ - **All four RPC kinds.** Unary, server streaming, client streaming and
38
+ bidirectional streaming, with backpressure in both directions.
39
+ - **Production defaults.** Graceful draining on SIGTERM, health checks
40
+ that flip to NOT_SERVING during shutdown, deadlines, message size and
41
+ connection limits, load shedding, gzip, TLS and mutual TLS.
42
+ - **Safe by default.** Unexpected exceptions reach clients as a generic
43
+ INTERNAL error (the details go to your logs and `Rails.error`).
44
+ Reflection is off unless you turn it on. TLS problems stop the server
45
+ instead of silently falling back to plaintext.
46
+ - **No Rust toolchain needed** on the platforms we ship precompiled gems
47
+ for (Linux and macOS, x86_64 and arm64).
48
+
49
+ ## Installation
50
+
51
+ ```ruby
52
+ # Gemfile
53
+ gem "tonic-rails"
54
+ gem "google-protobuf"
55
+ ```
56
+
57
+ Requires Ruby 3.3+ and Rails 7.1+ (or just ActiveSupport, outside Rails).
58
+ On platforms without a precompiled gem, installation builds the
59
+ extension from source, which needs a [Rust toolchain](https://rustup.rs).
60
+
61
+ To generate Ruby classes from your `.proto` files you also need `protoc`
62
+ (the Protocol Buffers compiler) during development.
63
+
64
+ ## Quick start
65
+
66
+ ### 1. Define the service
67
+
68
+ ```protobuf
69
+ // app/protos/items.proto
70
+ syntax = "proto3";
71
+ package demo.v1;
72
+
73
+ message Item { int64 id = 1; string name = 2; }
74
+ message GetItemRequest { int64 id = 1; }
75
+
76
+ service ItemService {
77
+ rpc GetItem(GetItemRequest) returns (Item);
78
+ }
79
+ ```
80
+
81
+ Generate the Ruby classes:
82
+
83
+ ```sh
84
+ bin/rails tonic_rails:compile_protos # app/protos → lib/grpc
85
+ ```
86
+
87
+ Generated files `require` each other by name, so keep them out of the
88
+ autoload paths and put their directory on the load path:
89
+
90
+ ```ruby
91
+ # config/application.rb
92
+ $LOAD_PATH.unshift(File.expand_path("../lib/grpc", __dir__))
93
+
94
+ module MyApp
95
+ class Application < Rails::Application
96
+ config.autoload_lib(ignore: %w[grpc])
97
+ end
98
+ end
99
+ ```
100
+
101
+ ### 2. Write a handler
102
+
103
+ Handlers live anywhere Rails autoloads from; `app/grpc` is a good home.
104
+ Each RPC maps to a snake_case method.
105
+
106
+ ```ruby
107
+ # app/grpc/item_service.rb
108
+ require "items_pb"
109
+
110
+ class ItemService
111
+ include Tonic::Rails::Base
112
+
113
+ rpc_service "demo.v1.ItemService" # every RPC, read from the descriptor
114
+ request_format :message # receive protobuf messages
115
+
116
+ def get_item(request)
117
+ item = Item.find(request.id)
118
+ Demo::V1::Item.new(id: item.id, name: item.name)
119
+ rescue ActiveRecord::RecordNotFound
120
+ raise Tonic::Rails::RpcError.new("item #{request.id} not found", code: :not_found)
121
+ end
122
+ end
123
+ ```
124
+
125
+ ### 3. Register and run
126
+
127
+ Register handlers in `to_prepare` so they work with code reloading:
128
+
129
+ ```ruby
130
+ # config/initializers/tonic_rails.rb
131
+ Rails.application.config.to_prepare do
132
+ Tonic::Rails.server.register(ItemService)
133
+ end
134
+ ```
135
+
136
+ ```sh
137
+ bin/rails tonic_rails:serve # serves on 0.0.0.0:50051
138
+ bin/rails tonic_rails:routes # lists registered methods
139
+ ```
140
+
141
+ Run it as its own process (a separate Procfile entry, container or
142
+ Kubernetes deployment) next to your web server.
143
+
144
+ The [example app](example/) is a complete, runnable version of all of the
145
+ above.
146
+
147
+ ## Handlers
148
+
149
+ ### Requests and responses
150
+
151
+ By default handlers receive a string-keyed Hash. With
152
+ `request_format :message` they receive the decoded protobuf message
153
+ instead, which is faster and type-safe. Either way, return a protobuf
154
+ message or a Hash:
155
+
156
+ ```ruby
157
+ def get_item(request)
158
+ { "id" => request.id, "name" => "Widget" } # a Hash works too
159
+ end
160
+ ```
161
+
162
+ A fresh handler instance serves each call, so instance variables never
163
+ leak between requests.
164
+
165
+ ### Declaring RPCs
166
+
167
+ `rpc_service "package.Service"` reads every RPC (and its streaming kind)
168
+ from the generated descriptor. To declare RPCs one at a time instead:
169
+
170
+ ```ruby
171
+ class ItemService
172
+ include Tonic::Rails::Base
173
+ service "demo.v1.ItemService"
174
+
175
+ rpc :GetItem, Demo::V1::GetItemRequest, Demo::V1::Item
176
+ rpc :ListItems, Demo::V1::ListItemsRequest, Demo::V1::Item, stream: :server
177
+ end
178
+ ```
179
+
180
+ RPCs declared without a handler method answer `UNIMPLEMENTED`.
181
+
182
+ ### Streaming
183
+
184
+ Server streaming: return any Enumerable. Messages go out as you produce
185
+ them, and if the client reads slowly your handler waits (without holding
186
+ the GVL) instead of buffering everything in memory.
187
+
188
+ ```ruby
189
+ def list_items(request)
190
+ Enumerator.new do |stream|
191
+ Item.where(tag: request.tag).find_each do |item|
192
+ break if cancelled? # the server is shutting down
193
+ stream << Demo::V1::Item.new(id: item.id, name: item.name)
194
+ end
195
+ end
196
+ end
197
+ ```
198
+
199
+ Client streaming: the argument is an Enumerator that yields each request
200
+ as it arrives.
201
+
202
+ ```ruby
203
+ def import_items(requests)
204
+ created = requests.count { |r| Item.create!(name: r.name) }
205
+ Demo::V1::ImportItemsResponse.new(created: created)
206
+ end
207
+ ```
208
+
209
+ Bidirectional streaming combines both: take the request Enumerator and
210
+ return an Enumerable. Responses can be sent before the client finishes
211
+ sending.
212
+
213
+ ```ruby
214
+ def chat(messages)
215
+ Enumerator.new do |out|
216
+ messages.each { |m| out << Demo::V1::Reply.new(text: m.text.upcase) }
217
+ end
218
+ end
219
+ ```
220
+
221
+ ### Errors
222
+
223
+ Raise `Tonic::Rails::RpcError` to end a call with a status:
224
+
225
+ ```ruby
226
+ raise Tonic::Rails::RpcError.new("name is required", code: :invalid_argument)
227
+ ```
228
+
229
+ Codes are the standard gRPC names (`:not_found`, `:permission_denied`,
230
+ `:unauthenticated`, `:unavailable`, and so on) or their numbers. The
231
+ message goes to the client, so keep internal details out of it.
232
+
233
+ Any other exception becomes `INTERNAL` with the message `internal error`.
234
+ The real exception is logged and passed to `Rails.error.report`, so your
235
+ error tracker sees it.
236
+
237
+ ### Metadata, deadlines and shutdown
238
+
239
+ ```ruby
240
+ def get_item(request)
241
+ token = grpc_metadata["authorization"] # request headers, lower-case
242
+ deadline_ms # ms left before the client's deadline, or nil
243
+ deadline_exceeded? # true once it has passed
244
+ cancelled? # true while the server shuts down
245
+ end
246
+ ```
247
+
248
+ When the deadline passes, the client gets `DEADLINE_EXCEEDED` right away;
249
+ check `deadline_exceeded?` in long loops to stop wasted work.
250
+
251
+ ### Interceptors
252
+
253
+ Interceptors wrap every call, outermost first. Call `next_call` to
254
+ continue, or raise to reject:
255
+
256
+ ```ruby
257
+ Tonic::Rails.configure do |config|
258
+ config.interceptors << lambda do |path, metadata, next_call|
259
+ unless valid_token?(metadata["authorization"])
260
+ raise Tonic::Rails::RpcError.new("invalid token", code: :unauthenticated)
261
+ end
262
+ next_call.call
263
+ end
264
+ end
265
+ ```
266
+
267
+ ## Testing handlers
268
+
269
+ `Tonic::Rails::Testing` calls a handler in-process, with real protobuf
270
+ encoding, no network and no native extension:
271
+
272
+ ```ruby
273
+ require "tonic/rails/testing"
274
+
275
+ response = Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 1 })
276
+ expect(response.name).to eq("Widget")
277
+
278
+ expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
279
+ .to raise_error(Tonic::Rails::RpcError) { |e| expect(e.code_name).to eq(:not_found) }
280
+
281
+ # Streaming: pass an Array of requests, get an Array of responses
282
+ Tonic::Rails::Testing.call(ItemService, :ImportItems, [{ name: "a" }, { name: "b" }])
283
+ ```
284
+
285
+ ## Configuration
286
+
287
+ Set options in `config/application.rb` or an environment file:
288
+
289
+ ```ruby
290
+ config.tonic_rails.bind_address = "0.0.0.0:50051"
291
+ config.tonic_rails.dispatch_threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
292
+ ```
293
+
294
+ or with a block (which runs after, and wins over, `config.tonic_rails`):
295
+
296
+ ```ruby
297
+ Tonic::Rails.configure do |config|
298
+ config.tls_cert_path = "/etc/tls/tls.crt"
299
+ config.tls_key_path = "/etc/tls/tls.key"
300
+ end
301
+ ```
302
+
303
+ | Option | Default | Description |
304
+ |--------|---------|-------------|
305
+ | `bind_address` | `"0.0.0.0:50051"` | Address to listen on |
306
+ | `dispatch_threads` | `4` | Ruby threads running handlers; match your database pool |
307
+ | `dispatch_queue_size` | `128` | Calls that may wait for a thread; beyond this, `RESOURCE_EXHAUSTED` |
308
+ | `worker_threads` | CPU count | Tokio threads for the transport |
309
+ | `max_message_size` | 4 MiB | Request and response limit (also caps decompressed size) |
310
+ | `max_connections` | `64` | Concurrent connections; `0` = unlimited |
311
+ | `concurrency_limit_per_connection` | `32` | Concurrent streams per connection; `0` = unlimited |
312
+ | `tls_cert_path`, `tls_key_path` | `nil` | PEM certificate and key; set both to enable TLS |
313
+ | `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
314
+ | `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
315
+ | `keepalive_interval`, `keepalive_timeout` | `15`, `5` | HTTP/2 PING interval and timeout, seconds |
316
+ | `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
317
+ | `reflection` | `false` | Serve `grpc.reflection.v1`; exposes your schema |
318
+ | `interceptors` | `[]` | See [Interceptors](#interceptors) |
319
+ | `log_requests` | `false` | Log every call to stderr (errors are always logged) |
320
+ | `auto_start` | `false` | Start inside `rails server` (see below) |
321
+
322
+ ### Sizing threads
323
+
324
+ Handlers run on `dispatch_threads` Ruby threads. Blocking I/O (database,
325
+ HTTP) releases the GVL, so more threads means more concurrent I/O. CPU
326
+ work does not parallelise within a process. Set threads to the
327
+ concurrency your I/O needs, size the database pool to match, and add
328
+ processes for CPU-heavy services: the listener uses `SO_REUSEPORT`, so
329
+ several processes can bind the same port.
330
+
331
+ ### auto_start
332
+
333
+ `auto_start = true` starts the gRPC server in a background thread of
334
+ `rails server` (never in consoles, rake tasks or tests). It's handy in
335
+ development. In production prefer a dedicated `tonic_rails:serve`
336
+ process: forking servers such as Puma in cluster mode would need to
337
+ start one per worker.
338
+
339
+ ## Operations
340
+
341
+ - **Health checks.** `grpc.health.v1.Health` reports `SERVING` for the
342
+ server (`""`) and every registered service, and flips to `NOT_SERVING`
343
+ as soon as shutdown begins. Point your load balancer or Kubernetes
344
+ gRPC probes at it.
345
+ - **Graceful shutdown.** On SIGTERM or SIGINT the server stops
346
+ accepting connections, tells clients to go away (HTTP/2 GOAWAY), lets
347
+ in-flight calls finish for up to `drain_timeout` seconds, then exits.
348
+ - **Load shedding.** When every dispatch thread is busy and the queue is
349
+ full, new calls fail fast with `RESOURCE_EXHAUSTED` instead of piling up.
350
+ - **Stats.** `Tonic::Rails.server.stats` returns counters for totals,
351
+ successes, errors, cancellations, deadline and resource errors, active
352
+ connections and queued calls.
353
+ - **Reflection.** Turn on `reflection` in development to use
354
+ [grpcurl](https://github.com/fullstorydev/grpcurl) without proto files.
355
+
356
+ ## How it works
357
+
358
+ ```
359
+ client ──HTTP/2──▶ tonic (Rust, no GVL)
360
+ ├─ TLS, flow control, keepalive, limits
361
+ ├─ grpc.health.v1, grpc.reflection.v1
362
+ └─ everything else ─▶ queue ─▶ Ruby dispatch thread
363
+ (takes the GVL)
364
+ decode → handler → encode
365
+ ```
366
+
367
+ Rust never decodes your messages; it passes raw protobuf bytes to Ruby,
368
+ where the `google-protobuf` C extension decodes them once. Routes come
369
+ from the handlers you register, so nothing about your services is
370
+ compiled into the extension.
371
+
372
+ ## Development
373
+
374
+ ```sh
375
+ bundle install
376
+ bundle exec rake compile # build the extension (needs Rust)
377
+ bundle exec rake # Ruby specs + Rust unit tests
378
+ bundle exec rubocop
379
+ bundle exec rake cargo:lint
380
+ ```
381
+
382
+ The integration specs start real servers and call them with the official
383
+ `grpc` gem; the reflection specs also need `grpcurl` on your PATH.
384
+ Benchmarks live in [bench/](bench/) (see [PERF.md](PERF.md)). See
385
+ [CONTRIBUTING.md](CONTRIBUTING.md) for more.
386
+
387
+ ## License
388
+
389
+ MIT. See [LICENSE](LICENSE).
390
+
391
+ [tonic]: https://github.com/hyperium/tonic
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/class/attribute"
4
+ require "active_support/core_ext/hash/keys"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ module Tonic
8
+ module Rails
9
+ # Include in a class to define a gRPC service handler.
10
+ #
11
+ # class ProductService
12
+ # include Tonic::Rails::Base
13
+ # rpc_service "shop.ProductService" # every RPC from the descriptor
14
+ #
15
+ # def get_product(request)
16
+ # Shop::Product.new(id: request.id, title: "Widget")
17
+ # end
18
+ # end
19
+ #
20
+ # Handler methods are the snake_case RPC names. A new instance is
21
+ # created for every call, so instance variables never leak between
22
+ # requests.
23
+ #
24
+ # Requests are decoded protobuf messages when `request_format :message`
25
+ # is set (fastest), or string-keyed Hashes by default. Handlers may
26
+ # return either a protobuf message or a Hash; streaming handlers
27
+ # return any Enumerable of them.
28
+ module Base
29
+ def self.included(base)
30
+ base.extend(ClassMethods)
31
+ base.class_attribute :grpc_service_name, instance_writer: false
32
+ base.class_attribute :grpc_rpcs, instance_writer: false, default: {}
33
+ base.class_attribute :grpc_request_format, instance_writer: false, default: :hash
34
+ end
35
+
36
+ module ClassMethods
37
+ # @param name [String] fully-qualified service name, e.g. "shop.ProductService"
38
+ def service(name)
39
+ self.grpc_service_name = name.to_s
40
+ end
41
+
42
+ # How requests are passed to handlers.
43
+ # @param format [Symbol] :hash (string-keyed Hash, the default) or
44
+ # :message (the decoded protobuf message — avoids a conversion)
45
+ def request_format(format)
46
+ unless %i[hash message].include?(format)
47
+ raise ArgumentError, "request_format must be :hash or :message, got #{format.inspect}"
48
+ end
49
+
50
+ self.grpc_request_format = format
51
+ end
52
+
53
+ # Declare one RPC.
54
+ # @param method_name [Symbol, String] RPC name as in the .proto, e.g. :GetProduct
55
+ # @param request_class [Class] protobuf request message class
56
+ # @param response_class [Class] protobuf response message class
57
+ # @param stream [Symbol, nil] :server, :client, :bidi, or nil for unary
58
+ def rpc(method_name, request_class, response_class, stream: nil)
59
+ unless [nil, :server, :client, :bidi].include?(stream)
60
+ raise ArgumentError, "stream must be :server, :client, :bidi, or nil, got #{stream.inspect}"
61
+ end
62
+
63
+ name = method_name.to_s
64
+ self.grpc_rpcs = grpc_rpcs.merge(
65
+ name => {
66
+ request: request_class,
67
+ response: response_class,
68
+ request_stream: %i[client bidi].include?(stream),
69
+ response_stream: %i[server bidi].include?(stream),
70
+ handler: name.underscore.to_sym,
71
+ }.freeze,
72
+ )
73
+ end
74
+
75
+ # Declare the service and every RPC in it from the protobuf
76
+ # descriptor pool. Load the generated `_pb.rb` file first.
77
+ #
78
+ # @param full_name [String] fully-qualified service name
79
+ def rpc_service(full_name)
80
+ descriptor = Google::Protobuf::DescriptorPool.generated_pool.lookup(full_name.to_s)
81
+ unless descriptor.is_a?(Google::Protobuf::ServiceDescriptor)
82
+ raise NotConfiguredError,
83
+ "rpc_service: #{full_name} is not a known protobuf service. " \
84
+ "Require its generated _pb.rb file (protoc --ruby_out) before declaring the handler."
85
+ end
86
+
87
+ service(full_name)
88
+ descriptor.each do |method|
89
+ stream = if method.client_streaming && method.server_streaming
90
+ :bidi
91
+ elsif method.client_streaming
92
+ :client
93
+ elsif method.server_streaming
94
+ :server
95
+ end
96
+ rpc(method.name, method.input_type.msgclass, method.output_type.msgclass, stream: stream)
97
+ end
98
+ end
99
+
100
+ # @api private Called by Server#register.
101
+ def register_on(router)
102
+ raise NotConfiguredError, "#{self} has no service name; call `service` or `rpc_service`" unless grpc_service_name
103
+ raise NotConfiguredError, "#{self} declares no RPCs" if grpc_rpcs.empty?
104
+
105
+ router.add(self)
106
+ end
107
+ end
108
+
109
+ # gRPC request metadata (lower-case header names). Binary (`-bin`)
110
+ # values are base64-encoded, as sent on the wire.
111
+ # @return [Hash{String=>String}]
112
+ attr_reader :grpc_metadata
113
+
114
+ # Milliseconds left before the client's deadline, or nil if the
115
+ # client did not set one.
116
+ # @return [Integer, nil]
117
+ def deadline_ms
118
+ return unless @grpc_deadline_at
119
+
120
+ remaining = ((@grpc_deadline_at - Process.clock_gettime(Process::CLOCK_MONOTONIC)) * 1000).to_i
121
+ remaining.negative? ? 0 : remaining
122
+ end
123
+
124
+ # True when the deadline has passed.
125
+ def deadline_exceeded?
126
+ !deadline_ms.nil? && deadline_ms.zero?
127
+ end
128
+
129
+ # True when the server is shutting down.
130
+ def cancelled?
131
+ Tonic::Rails.cancelled?
132
+ end
133
+
134
+ # @api private
135
+ # Run one RPC. `reader` yields raw request messages (`#next`, then
136
+ # `#error` once exhausted); `sender` accepts raw response messages
137
+ # (`#push`) for server-streaming RPCs.
138
+ # @return [String, nil] the encoded unary response, or nil when
139
+ # messages were streamed through `sender`
140
+ def call_rpc(rpc_name, metadata:, reader:, sender: nil, timeout_ms: nil)
141
+ spec = self.class.grpc_rpcs[rpc_name.to_s]
142
+ raise RpcError.new("method not implemented", code: :unimplemented) unless spec
143
+ raise RpcError.new("method #{rpc_name} not implemented", code: :unimplemented) unless respond_to?(spec[:handler])
144
+
145
+ @grpc_metadata = metadata
146
+ @grpc_deadline_at = timeout_ms && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + (timeout_ms / 1000.0))
147
+
148
+ request = if spec[:request_stream]
149
+ Enumerator.new do |y|
150
+ while (message = read_request(reader, spec))
151
+ y << message
152
+ end
153
+ end
154
+ else
155
+ read_request(reader, spec) || raise(RpcError.new("missing request message", code: :invalid_argument))
156
+ end
157
+
158
+ result = public_send(spec[:handler], request)
159
+
160
+ if spec[:response_stream]
161
+ unless result.respond_to?(:each)
162
+ raise TypeError, "streaming handler #{self.class}##{spec[:handler]} must return an Enumerable, got #{result.class}"
163
+ end
164
+
165
+ result.each { |item| push_response(sender, encode_response(item, spec)) }
166
+ nil
167
+ else
168
+ encode_response(result, spec)
169
+ end
170
+ rescue RpcError
171
+ raise
172
+ rescue StandardError => e
173
+ Tonic::Rails.log_exception(e, "#{self.class}##{rpc_name}")
174
+ raise RpcError.new("internal error", code: :internal)
175
+ end
176
+
177
+ private
178
+
179
+ def read_request(reader, spec)
180
+ bytes = reader.next
181
+ if bytes.nil?
182
+ code, message = reader.error
183
+ raise RpcError.new(message, code: code) if code
184
+
185
+ return
186
+ end
187
+
188
+ message = begin
189
+ spec[:request].decode(bytes)
190
+ rescue Google::Protobuf::ParseError, ArgumentError
191
+ raise RpcError.new("invalid protobuf request", code: :invalid_argument)
192
+ end
193
+ self.class.grpc_request_format == :message ? message : message.to_h.deep_stringify_keys
194
+ end
195
+
196
+ def encode_response(item, spec)
197
+ klass = spec[:response]
198
+ message = case item
199
+ when klass then item
200
+ when Hash then klass.new(item)
201
+ else
202
+ raise TypeError, "#{self.class}##{spec[:handler]} must return a #{klass} or Hash, got #{item.class}"
203
+ end
204
+ klass.encode(message)
205
+ end
206
+
207
+ def push_response(sender, bytes)
208
+ sender.push(bytes)
209
+ rescue IOError
210
+ # The client went away; nothing left to deliver to.
211
+ raise RpcError.new("client cancelled the stream", code: :cancelled)
212
+ end
213
+ end
214
+ end
215
+ end