tonic-rails 0.1.0-x86_64-linux-gnu → 0.3.0-x86_64-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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -2
  3. data/README.md +204 -13
  4. data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
  5. data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
  6. data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
  7. data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
  8. data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
  9. data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
  10. data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
  11. data/lib/puma/plugin/tonic_rails.rb +116 -0
  12. data/lib/tonic/rails/3.3/tonic_rails_native.so +0 -0
  13. data/lib/tonic/rails/3.4/tonic_rails_native.so +0 -0
  14. data/lib/tonic/rails/4.0/tonic_rails_native.so +0 -0
  15. data/lib/tonic/rails/base.rb +169 -23
  16. data/lib/tonic/rails/configuration.rb +27 -4
  17. data/lib/tonic/rails/health.rb +105 -0
  18. data/lib/tonic/rails/instrumentation.rb +78 -0
  19. data/lib/tonic/rails/metadata.rb +50 -0
  20. data/lib/tonic/rails/peer.rb +60 -0
  21. data/lib/tonic/rails/railtie.rb +2 -2
  22. data/lib/tonic/rails/router.rb +1 -0
  23. data/lib/tonic/rails/rpc_error.rb +73 -6
  24. data/lib/tonic/rails/server.rb +82 -12
  25. data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
  26. data/lib/tonic/rails/testing/errors.rb +47 -0
  27. data/lib/tonic/rails/testing/minitest.rb +44 -0
  28. data/lib/tonic/rails/testing/rspec.rb +53 -0
  29. data/lib/tonic/rails/testing.rb +35 -4
  30. data/lib/tonic/rails/version.rb +1 -1
  31. data/lib/tonic-rails.rb +4 -0
  32. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b80385e6e34361d398f683ec43379d519558c45f7762280c278bb52c545316f6
4
- data.tar.gz: 1f25a57bf202c59274c178981967aeee274d33ab85aaf6517ca3261ae115a8ae
3
+ metadata.gz: 3b594b6ba6699510bedb286cab5e05fc8fa3578d200fdb704fd8335529ee0105
4
+ data.tar.gz: df3ddc7f65ca300dce59a26855fcadfd88f573c9f892039afa727ec16a3dc01e
5
5
  SHA512:
6
- metadata.gz: 9a6f385bfd4b2fe956e225888cca22663302b31f6a8c3ca97c3edb3a6f6b5ec13ba1224cc37788724ababd4c518e9eaf06f6234c4aa9d380ec6aeffc9b959af1
7
- data.tar.gz: a9e4c94967a476ceb84d11135fe3c6a0755219c3808736692d656df6683587d8e362dd00bb3b8f6b8f1ddddf8a0625fdc0bf6c0918c1529b5e97d9a24152baad
6
+ metadata.gz: 0d8da9f21bc78c95c31b708a0349d54f23e9a5395bad4de0d46937ec1fae721bd6590a74d498dd523e087b1a1c90b23af6a10c93f2cfaab262d554e3c74272a0
7
+ data.tar.gz: a67219c39168b2eeae48a638b73ebdfa65a4c5a0300138577088f214868db989e36196647639753563584e49dfb256da0175f2b13aac0b8526d30cb9cee38f44
data/CHANGELOG.md CHANGED
@@ -7,7 +7,75 @@ minor versions may contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.1.0]
10
+ ## [0.3.0] - 2026-10-11
11
+
12
+ ### Added
13
+
14
+ - Generators: `rails g tonic_rails:install` (initializer, proto and
15
+ generated-code directories, Puma plugin) and `rails g tonic_rails:service
16
+ Item GetItem ListItems:server` (proto, handler with a stub per RPC,
17
+ registration, and an RSpec or Minitest test).
18
+ - Controller-style callbacks for handlers: `before_rpc`, `after_rpc` and
19
+ `around_rpc` with `only:` / `except:`, plus `current_rpc` and `request`
20
+ accessors.
21
+ - `rescue_from` (ActiveSupport::Rescuable) with a `code:` shorthand that maps
22
+ an exception class to a gRPC status.
23
+ - `peer` in handlers: the client's address and, with mutual TLS, its
24
+ certificate (`peer.certificate`, an `OpenSSL::X509::Certificate`).
25
+ `Testing.call`/`invoke` accept `peer:`.
26
+ - App-controlled health: `config.health_check` (polled every
27
+ `health_check_interval` seconds inside the Rails executor) and
28
+ `Tonic::Rails.server.serving!` / `not_serving!` for the whole server or
29
+ individual services, so readiness probes reflect real dependency health.
30
+ - Test helpers: the `raise_grpc_error(code).with_message(...)` RSpec matcher
31
+ (`require "tonic/rails/testing/rspec"`) and `assert_grpc_error` for Minitest
32
+ (`Tonic::Rails::Testing::Assertions`). Both accept `Tonic::Rails::RpcError`
33
+ and the grpc gem's `GRPC::BadStatus`.
34
+
35
+ ### Changed
36
+
37
+ - `tonic_rails:compile_protos` requires protoc 22 or newer and says so,
38
+ instead of generating code google-protobuf 4 can't load (older protoc,
39
+ such as Ubuntu's 3.21 package, emits a Ruby DSL that google-protobuf 4
40
+ removed).
41
+
42
+ ## [0.2.0] - 2026-10-11
43
+
44
+ ### Added
45
+
46
+ - Instrumentation: every RPC publishes a `process_rpc.tonic_rails`
47
+ ActiveSupport::Notifications event (path, service, rpc, metadata, status,
48
+ code) for APM agents, OpenTelemetry and custom subscribers. With no
49
+ subscribers it costs nothing measurable.
50
+ - Response metadata: handlers set `response_metadata` (headers) and
51
+ `trailing_metadata` (trailers, sent on success or failure).
52
+ - Rich errors: `RpcError.new(..., metadata:, details:)` sends trailing
53
+ metadata and a `google.rpc.Status` with packed details in
54
+ `grpc-status-details-bin`, which official gRPC clients decode.
55
+ - Puma plugin: `plugin :tonic_rails` in `config/puma.rb` runs the gRPC
56
+ server as a child process of Puma, in single and cluster mode, with
57
+ graceful stop and restart, and a shutdown if either side dies.
58
+ - Windows: precompiled `x64-mingw-ucrt` gems, tested in CI.
59
+ - `Tonic::Rails::Testing.invoke` returns the response together with its
60
+ headers and trailers.
61
+
62
+ ### Changed
63
+
64
+ - Response headers are sent with the handler's first output instead of
65
+ before the handler runs, so handlers can set them. The client deadline
66
+ applies while waiting.
67
+ - `log_requests` logs one line per RPC through the configured (Rails)
68
+ logger instead of stderr.
69
+ - Transport events from the native extension (rejected calls, expired
70
+ deadlines, refused connections, faults) go through the configured (Rails)
71
+ logger with severities instead of stderr. Handler outcomes, such as a
72
+ `NOT_FOUND` raised by a handler, are no longer logged by the transport;
73
+ use `log_requests` or the instrumentation event for those.
74
+ - `Server#stop` signals shutdown through a queue instead of a pipe, so
75
+ signal handling works the same on every platform.
76
+ - Dependencies: magnus 0.9, google-protobuf 4.36.
77
+
78
+ ## [0.1.0] - 2026-10-10
11
79
 
12
80
  First public release.
13
81
 
@@ -48,5 +116,7 @@ First public release.
48
116
  - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
49
117
  macOS (x86_64, arm64), Ruby 3.3 to 4.0.
50
118
 
51
- [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...HEAD
119
+ [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...HEAD
120
+ [0.3.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.0...v0.3.0
121
+ [0.2.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...v0.2.0
52
122
  [0.1.0]: https://github.com/CodingAnarchy/tonic-rails/releases/tag/v0.1.0
data/README.md CHANGED
@@ -30,10 +30,11 @@ end
30
30
 
31
31
  ## Why tonic-rails
32
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).
33
+ - **Fast where it counts.** Inside a Rails app, light RPCs ran at 5–6×
34
+ the throughput of an equivalent Rails JSON endpoint and 1.4–3× the
35
+ official `grpc` gem, and server streaming at 4–7× the `grpc` gem.
36
+ Handlers dominated by slow queries or heavy object building see smaller
37
+ gains. See [PERF.md](PERF.md).
37
38
  - **All four RPC kinds.** Unary, server streaming, client streaming and
38
39
  bidirectional streaming, with backpressure in both directions.
39
40
  - **Production defaults.** Graceful draining on SIGTERM, health checks
@@ -44,7 +45,7 @@ end
44
45
  Reflection is off unless you turn it on. TLS problems stop the server
45
46
  instead of silently falling back to plaintext.
46
47
  - **No Rust toolchain needed** on the platforms we ship precompiled gems
47
- for (Linux and macOS, x86_64 and arm64).
48
+ for: Linux and macOS (x86_64 and arm64) and Windows (x64).
48
49
 
49
50
  ## Installation
50
51
 
@@ -59,10 +60,31 @@ On platforms without a precompiled gem, installation builds the
59
60
  extension from source, which needs a [Rust toolchain](https://rustup.rs).
60
61
 
61
62
  To generate Ruby classes from your `.proto` files you also need `protoc`
62
- (the Protocol Buffers compiler) during development.
63
+ (the Protocol Buffers compiler) **22.0 or newer** during development.
64
+ Older versions, such as Ubuntu's `protobuf-compiler` package (3.21),
65
+ generate code that google-protobuf 4 can't load; install a current
66
+ release from the [protobuf releases page](https://github.com/protocolbuffers/protobuf/releases)
67
+ or Homebrew.
63
68
 
64
69
  ## Quick start
65
70
 
71
+ The generators set everything up:
72
+
73
+ ```sh
74
+ bin/rails generate tonic_rails:install
75
+ bin/rails generate tonic_rails:service Item GetItem ListItems:server
76
+ bin/rails tonic_rails:compile_protos
77
+ ```
78
+
79
+ `install` adds `config/initializers/tonic_rails.rb` (load path for
80
+ generated code, configuration, handler registration), `app/protos/`,
81
+ `lib/grpc/`, and the Puma plugin. `service` writes the `.proto`, a handler
82
+ in `app/grpc/` with a stub per RPC, its registration, and a spec (or
83
+ Minitest test). RPCs take an optional `:server`, `:client` or `:bidi`
84
+ streaming kind.
85
+
86
+ The rest of this section shows the same steps by hand.
87
+
66
88
  ### 1. Define the service
67
89
 
68
90
  ```protobuf
@@ -139,7 +161,8 @@ bin/rails tonic_rails:routes # lists registered methods
139
161
  ```
140
162
 
141
163
  Run it as its own process (a separate Procfile entry, container or
142
- Kubernetes deployment) next to your web server.
164
+ Kubernetes deployment) next to your web server, or let Puma run it for
165
+ you (see [Running with Puma](#running-with-puma)).
143
166
 
144
167
  The [example app](example/) is a complete, runnable version of all of the
145
168
  above.
@@ -234,11 +257,54 @@ Any other exception becomes `INTERNAL` with the message `internal error`.
234
257
  The real exception is logged and passed to `Rails.error.report`, so your
235
258
  error tracker sees it.
236
259
 
260
+ ### Callbacks and rescue_from
261
+
262
+ Handlers get controller-style callbacks and exception mapping:
263
+
264
+ ```ruby
265
+ class ItemService
266
+ include Tonic::Rails::Base
267
+ rpc_service "demo.v1.ItemService"
268
+
269
+ before_rpc :authenticate, except: :ListItems
270
+ around_rpc :with_tenant
271
+ after_rpc { StatsD.increment("grpc.#{current_rpc}") }
272
+
273
+ rescue_from ActiveRecord::RecordNotFound, code: :not_found
274
+ rescue_from ActiveRecord::RecordInvalid, code: :invalid_argument, message: ->(e) { e.record.errors.full_messages.join(", ") }
275
+ rescue_from Pundit::NotAuthorizedError, with: :forbidden
276
+
277
+ private
278
+
279
+ def authenticate
280
+ @user = User.from_token(grpc_metadata["authorization"])
281
+ raise Tonic::Rails::RpcError.new("invalid token", code: :unauthenticated) unless @user
282
+ end
283
+
284
+ def with_tenant(&) = Tenant.switch(@user.tenant, &)
285
+
286
+ def forbidden(_e) = raise(Tonic::Rails::RpcError.new("not allowed", code: :permission_denied))
287
+ end
288
+ ```
289
+
290
+ - `before_rpc`, `after_rpc` and `around_rpc` accept method names or a
291
+ block, plus `only:` / `except:` with RPC names (`:GetItem` or
292
+ `:get_item`). Raising in a `before_rpc` stops the call.
293
+ - `current_rpc` is the RPC being handled and `request` the decoded
294
+ request, so callbacks can inspect it.
295
+ - `after_rpc` runs once a streamed response has been fully sent.
296
+ - `rescue_from` covers the handler and its callbacks. With `code:`, the
297
+ exception's message is sent to the client unless you pass `message:`.
298
+ Handlers given with `with:` or a block must raise an `RpcError`.
299
+ Anything unmapped becomes `INTERNAL`, as before.
300
+
237
301
  ### Metadata, deadlines and shutdown
238
302
 
239
303
  ```ruby
240
304
  def get_item(request)
241
305
  token = grpc_metadata["authorization"] # request headers, lower-case
306
+ peer.ip # client address ("10.0.4.17")
307
+ peer.certificate&.subject # client certificate, with mutual TLS
242
308
  deadline_ms # ms left before the client's deadline, or nil
243
309
  deadline_exceeded? # true once it has passed
244
310
  cancelled? # true while the server shuts down
@@ -248,6 +314,39 @@ end
248
314
  When the deadline passes, the client gets `DEADLINE_EXCEEDED` right away;
249
315
  check `deadline_exceeded?` in long loops to stop wasted work.
250
316
 
317
+ ### Response metadata
318
+
319
+ Set response headers and trailers from a handler:
320
+
321
+ ```ruby
322
+ def get_item(request)
323
+ response_metadata["x-cache"] = "hit" # headers
324
+ trailing_metadata["x-cost-ms"] = "3" # trailers, sent with the status
325
+ response_metadata["x-trace-bin"] = trace_bytes # -bin names take binary values
326
+ Demo::V1::Item.new(id: request.id)
327
+ end
328
+ ```
329
+
330
+ For streaming responses, set headers before the first message is sent.
331
+ Trailers are sent whether the call succeeds or fails.
332
+
333
+ ### Rich errors
334
+
335
+ `RpcError` can carry trailing metadata and
336
+ [rich error details](https://grpc.io/docs/guides/error/#richer-error-model):
337
+ protobuf messages packed into a `google.rpc.Status` in the standard
338
+ `grpc-status-details-bin` trailer, which official gRPC clients decode for
339
+ you.
340
+
341
+ ```ruby
342
+ raise Tonic::Rails::RpcError.new(
343
+ "invalid item",
344
+ code: :invalid_argument,
345
+ metadata: { "x-request-id" => request_id },
346
+ details: [Google::Rpc::BadRequest.new(field_violations: violations)],
347
+ )
348
+ ```
349
+
251
350
  ### Interceptors
252
351
 
253
352
  Interceptors wrap every call, outermost first. Call `next_call` to
@@ -280,6 +379,35 @@ expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
280
379
 
281
380
  # Streaming: pass an Array of requests, get an Array of responses
282
381
  Tonic::Rails::Testing.call(ItemService, :ImportItems, [{ name: "a" }, { name: "b" }])
382
+
383
+ # Response metadata
384
+ result = Tonic::Rails::Testing.invoke(ItemService, :GetItem, { id: 1 })
385
+ result.headers["x-cache"] # => "hit"
386
+ result.trailers["x-cost-ms"] # => "3"
387
+ ```
388
+
389
+ Matchers for gRPC statuses work with `Testing.call` and with the official
390
+ `grpc` client's errors:
391
+
392
+ ```ruby
393
+ # spec/rails_helper.rb
394
+ require "tonic/rails/testing/rspec"
395
+
396
+ expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
397
+ .to raise_grpc_error(:not_found).with_message(/0/)
398
+ ```
399
+
400
+ ```ruby
401
+ # Minitest
402
+ require "tonic/rails/testing/minitest"
403
+
404
+ class ItemServiceTest < ActiveSupport::TestCase
405
+ include Tonic::Rails::Testing::Assertions
406
+
407
+ test "missing items" do
408
+ assert_grpc_error(:not_found) { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
409
+ end
410
+ end
283
411
  ```
284
412
 
285
413
  ## Configuration
@@ -313,11 +441,13 @@ end
313
441
  | `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
314
442
  | `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
315
443
  | `keepalive_interval`, `keepalive_timeout` | `15`, `5` | HTTP/2 PING interval and timeout, seconds |
444
+ | `health_check` | `nil` | Callable; while false or raising, health reports `NOT_SERVING` |
445
+ | `health_check_interval` | `5` | Seconds between health checks |
316
446
  | `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
317
447
  | `reflection` | `false` | Serve `grpc.reflection.v1`; exposes your schema |
318
448
  | `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) |
449
+ | `log_requests` | `false` | Log one line per call through `logger` (unexpected exceptions are always logged) |
450
+ | `auto_start` | `false` | Start in a thread of `rails server`; for development (see [auto_start](#auto_start)) |
321
451
 
322
452
  ### Sizing threads
323
453
 
@@ -328,31 +458,92 @@ concurrency your I/O needs, size the database pool to match, and add
328
458
  processes for CPU-heavy services: the listener uses `SO_REUSEPORT`, so
329
459
  several processes can bind the same port.
330
460
 
461
+ ### Running with Puma
462
+
463
+ Add the plugin to `config/puma.rb`:
464
+
465
+ ```ruby
466
+ plugin :tonic_rails
467
+ ```
468
+
469
+ Puma then runs the gRPC server as one child process, in single and cluster
470
+ mode alike, so gRPC gets its own process and GVL instead of competing with
471
+ web workers. It starts after Puma boots and drains gracefully when Puma
472
+ stops or restarts. If the gRPC process dies, Puma shuts down too, so your
473
+ supervisor restarts both. It works with `rails server`, plain `puma`, and
474
+ `preload_app!`. (Not on Windows, which has no `fork()`: run
475
+ `tonic_rails:serve` as its own process there.)
476
+
477
+ For larger deployments, a dedicated `bin/rails tonic_rails:serve` process
478
+ (scaled separately) is still the most flexible option.
479
+
331
480
  ### auto_start
332
481
 
333
482
  `auto_start = true` starts the gRPC server in a background thread of
334
483
  `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.
484
+ development; in production use the Puma plugin or a dedicated process.
485
+ Don't combine it with the plugin.
338
486
 
339
487
  ## Operations
340
488
 
341
489
  - **Health checks.** `grpc.health.v1.Health` reports `SERVING` for the
342
490
  server (`""`) and every registered service, and flips to `NOT_SERVING`
343
491
  as soon as shutdown begins. Point your load balancer or Kubernetes
344
- gRPC probes at it.
492
+ gRPC probes at it. To make readiness reflect your dependencies, add a
493
+ check that runs every `health_check_interval` seconds (default 5):
494
+
495
+ ```ruby
496
+ config.health_check = -> { ActiveRecord::Base.connection.active? }
497
+ ```
498
+
499
+ While it returns false or raises, the server reports `NOT_SERVING`
500
+ (calls are still served). You can also mark the server or a service
501
+ yourself, for example during maintenance:
502
+ `Tonic::Rails.server.not_serving!("demo.v1.ItemService")` and
503
+ `serving!`. A service is `SERVING` only while the check passes and it
504
+ isn't marked otherwise.
345
505
  - **Graceful shutdown.** On SIGTERM or SIGINT the server stops
346
506
  accepting connections, tells clients to go away (HTTP/2 GOAWAY), lets
347
507
  in-flight calls finish for up to `drain_timeout` seconds, then exits.
348
508
  - **Load shedding.** When every dispatch thread is busy and the queue is
349
509
  full, new calls fail fast with `RESOURCE_EXHAUSTED` instead of piling up.
510
+ - **Instrumentation.** Every RPC publishes a `process_rpc.tonic_rails`
511
+ event through `ActiveSupport::Notifications`, so APM agents,
512
+ OpenTelemetry and your own subscribers can trace calls. See
513
+ [Instrumentation](#instrumentation).
514
+ - **Logs.** Everything goes through your (Rails) logger. With
515
+ `log_requests = true`, each RPC logs one line, e.g.
516
+ `gRPC /demo.v1.ItemService/GetItem OK in 1.4ms`. Calls the transport
517
+ rejects before reaching Ruby (unknown methods, a full queue, expired
518
+ deadlines, oversized messages, refused connections) are always logged,
519
+ at info for client-caused problems, warn for overload and error for faults.
350
520
  - **Stats.** `Tonic::Rails.server.stats` returns counters for totals,
351
521
  successes, errors, cancellations, deadline and resource errors, active
352
522
  connections and queued calls.
353
523
  - **Reflection.** Turn on `reflection` in development to use
354
524
  [grpcurl](https://github.com/fullstorydev/grpcurl) without proto files.
355
525
 
526
+ ## Instrumentation
527
+
528
+ Every RPC is wrapped in an `ActiveSupport::Notifications` event, inside the
529
+ Rails executor, so subscribers see the same context as your handler:
530
+
531
+ ```ruby
532
+ ActiveSupport::Notifications.subscribe("process_rpc.tonic_rails") do |event|
533
+ event.payload[:path] # "/demo.v1.ItemService/GetItem"
534
+ event.payload[:service] # "demo.v1.ItemService"
535
+ event.payload[:rpc] # "GetItem"
536
+ event.payload[:metadata] # request metadata
537
+ event.payload[:status] # :ok, :not_found, :internal, ...
538
+ event.payload[:code] # numeric gRPC status
539
+ event.duration # milliseconds
540
+ end
541
+ ```
542
+
543
+ When a call fails, `event.payload[:exception_object]` is the
544
+ `Tonic::Rails::RpcError` sent to the client. If a handler raised something
545
+ unexpected, that error's `cause` is the original exception.
546
+
356
547
  ## How it works
357
548
 
358
549
  ```
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module TonicRails
6
+ module Generators
7
+ # rails generate tonic_rails:install
8
+ #
9
+ # Sets an app up for gRPC: an initializer (load path for generated
10
+ # protobuf code, configuration, handler registration), the proto and
11
+ # generated-code directories, and the Puma plugin.
12
+ class InstallGenerator < ::Rails::Generators::Base
13
+ source_root File.expand_path("templates", __dir__)
14
+
15
+ class_option :skip_puma, type: :boolean, default: false,
16
+ desc: "Don't add `plugin :tonic_rails` to config/puma.rb"
17
+ class_option :skip_next_steps, type: :boolean, default: false, hide: true
18
+
19
+ def create_initializer
20
+ template "tonic_rails.rb.tt", "config/initializers/tonic_rails.rb"
21
+ end
22
+
23
+ def create_directories
24
+ create_file "app/protos/.keep"
25
+ create_file "lib/grpc/.keep"
26
+ end
27
+
28
+ def add_puma_plugin
29
+ return if options[:skip_puma]
30
+
31
+ puma = "config/puma.rb"
32
+ return unless File.exist?(File.join(destination_root, puma))
33
+ return if File.read(File.join(destination_root, puma)).match?(/plugin\s+:tonic_rails/)
34
+
35
+ append_to_file puma, "\n# Run the tonic-rails gRPC server alongside Puma.\nplugin :tonic_rails\n"
36
+ end
37
+
38
+ def show_next_steps
39
+ return if options[:skip_next_steps]
40
+
41
+ say "\nNext: rails generate tonic_rails:service Item GetItem ListItems:server", :green
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Ruby code generated from app/protos by `bin/rails tonic_rails:compile_protos`
4
+ # lives in lib/grpc. Its files require each other by plain name, so put the
5
+ # directory on the load path and keep Zeitwerk away from it.
6
+ Rails.root.join("lib/grpc").then do |generated|
7
+ $LOAD_PATH.unshift(generated.to_s) unless $LOAD_PATH.include?(generated.to_s)
8
+ Rails.autoloaders.main.ignore(generated)
9
+ end
10
+
11
+ Tonic::Rails.configure do |config|
12
+ # config.bind_address = "0.0.0.0:50051"
13
+ # config.dispatch_threads = ENV.fetch("RAILS_MAX_THREADS", 5).to_i
14
+ # config.reflection = Rails.env.development?
15
+ # config.health_check = -> { ActiveRecord::Base.connection.active? }
16
+ end
17
+
18
+ # Handlers in app/grpc are autoloaded; register them here so they're
19
+ # picked up again after code reloads.
20
+ Rails.application.config.to_prepare do
21
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module TonicRails
6
+ module Generators
7
+ # rails generate tonic_rails:service Item GetItem ListItems:server ImportItems:client Sync:bidi
8
+ #
9
+ # Creates a .proto with the service and its messages, a handler with a
10
+ # method per RPC, the handler's registration, and a test.
11
+ class ServiceGenerator < ::Rails::Generators::NamedBase
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ STREAM_KINDS = %w[unary server client bidi].freeze
15
+
16
+ argument :rpcs, type: :array, default: [], banner: "Rpc[:server|:client|:bidi] ..."
17
+
18
+ class_option :package, type: :string,
19
+ desc: "Protobuf package (default: <app name>.v1)"
20
+
21
+ def check_rpcs
22
+ rpc_specs.each do |rpc|
23
+ next if rpc[:name].match?(/\A[A-Z][A-Za-z0-9]*\z/) && STREAM_KINDS.include?(rpc[:kind])
24
+
25
+ raise Thor::Error, "Invalid RPC #{rpc[:raw].inspect}: use CamelCase names, optionally :server, :client or :bidi"
26
+ end
27
+ end
28
+
29
+ def create_proto
30
+ template "service.proto.tt", "app/protos/#{package_path}/#{service_file}_service.proto"
31
+ end
32
+
33
+ def create_handler
34
+ template "handler.rb.tt", "app/grpc/#{service_file}_service.rb"
35
+ end
36
+
37
+ def register_handler
38
+ initializer = "config/initializers/tonic_rails.rb"
39
+ invoke "tonic_rails:install", [], skip_next_steps: true unless File.exist?(File.join(destination_root, initializer))
40
+ inject_into_file initializer, " Tonic::Rails.server.register(#{service_class})\n",
41
+ after: "Rails.application.config.to_prepare do\n"
42
+ end
43
+
44
+ def create_test
45
+ if File.directory?(File.join(destination_root, "spec"))
46
+ template "service_spec.rb.tt", "spec/grpc/#{service_file}_service_spec.rb"
47
+ else
48
+ template "service_test.rb.tt", "test/grpc/#{service_file}_service_test.rb"
49
+ end
50
+ end
51
+
52
+ def show_next_steps
53
+ say "\nNext: bin/rails tonic_rails:compile_protos, then fill in app/grpc/#{service_file}_service.rb", :green
54
+ end
55
+
56
+ private
57
+
58
+ # "Item" and "ItemService" both mean the service ItemService.
59
+ def service_base
60
+ class_name.delete_suffix("Service")
61
+ end
62
+
63
+ def service_file
64
+ service_base.underscore
65
+ end
66
+
67
+ def service_class
68
+ "#{service_base}Service"
69
+ end
70
+
71
+ def package
72
+ options[:package] || "#{app_name}.v1"
73
+ end
74
+
75
+ def app_name
76
+ app = defined?(::Rails.application) && ::Rails.application
77
+ name = app ? app.class.module_parent_name : File.basename(destination_root)
78
+ name.to_s.underscore.tr("-", "_").presence || "app"
79
+ end
80
+
81
+ def package_path
82
+ package.tr(".", "/")
83
+ end
84
+
85
+ def ruby_module
86
+ package.split(".").map(&:camelize).join("::")
87
+ end
88
+
89
+ def full_service_name
90
+ "#{package}.#{service_class}"
91
+ end
92
+
93
+ def rpc_specs
94
+ list = rpcs.empty? ? ["Get#{service_base}"] : rpcs
95
+ list.map do |raw|
96
+ name, kind = raw.split(":", 2)
97
+ { raw: raw, name: name.to_s, kind: kind || "unary", method: name.to_s.underscore }
98
+ end
99
+ end
100
+
101
+ def proto_rpc_line(rpc)
102
+ request = "#{rpc[:name]}Request"
103
+ response = "#{rpc[:name]}Response"
104
+ request = "stream #{request}" if %w[client bidi].include?(rpc[:kind])
105
+ response = "stream #{response}" if %w[server bidi].include?(rpc[:kind])
106
+ "rpc #{rpc[:name]}(#{request}) returns (#{response});"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "<%= package_path %>/<%= service_file %>_service_pb"
4
+
5
+ class <%= service_class %>
6
+ include Tonic::Rails::Base
7
+
8
+ rpc_service "<%= full_service_name %>"
9
+ request_format :message
10
+ <% rpc_specs.each do |rpc| %>
11
+ <% case rpc[:kind] -%>
12
+ <% when "unary" -%>
13
+ # @param request [<%= ruby_module %>::<%= rpc[:name] %>Request]
14
+ # @return [<%= ruby_module %>::<%= rpc[:name] %>Response]
15
+ def <%= rpc[:method] %>(request)
16
+ raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
17
+ end
18
+ <% when "server" -%>
19
+ # @param request [<%= ruby_module %>::<%= rpc[:name] %>Request]
20
+ # @return [Enumerable<<%= ruby_module %>::<%= rpc[:name] %>Response>] each element is streamed as it's produced
21
+ def <%= rpc[:method] %>(request)
22
+ raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
23
+ end
24
+ <% when "client" -%>
25
+ # @param requests [Enumerator<<%= ruby_module %>::<%= rpc[:name] %>Request>] yields messages as they arrive
26
+ # @return [<%= ruby_module %>::<%= rpc[:name] %>Response]
27
+ def <%= rpc[:method] %>(requests)
28
+ raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
29
+ end
30
+ <% else -%>
31
+ # @param requests [Enumerator<<%= ruby_module %>::<%= rpc[:name] %>Request>] yields messages as they arrive
32
+ # @return [Enumerable<<%= ruby_module %>::<%= rpc[:name] %>Response>]
33
+ def <%= rpc[:method] %>(requests)
34
+ raise Tonic::Rails::RpcError.new("<%= rpc[:name] %> is not implemented yet", code: :unimplemented)
35
+ end
36
+ <% end -%>
37
+ <% end -%>
38
+ end
@@ -0,0 +1,16 @@
1
+ syntax = "proto3";
2
+
3
+ package <%= package %>;
4
+
5
+ service <%= service_class %> {
6
+ <% rpc_specs.each do |rpc| -%>
7
+ <%= proto_rpc_line(rpc) %>
8
+ <% end -%>
9
+ }
10
+ <% rpc_specs.each do |rpc| %>
11
+ message <%= rpc[:name] %>Request {
12
+ }
13
+
14
+ message <%= rpc[:name] %>Response {
15
+ }
16
+ <% end -%>
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails_helper"
4
+ require "tonic/rails/testing/rspec"
5
+
6
+ RSpec.describe <%= service_class %> do
7
+ <% rpc_specs.each_with_index do |rpc, i| -%>
8
+ <% if i.positive? %>
9
+ <% end -%>
10
+ describe "<%= rpc[:name] %>" do
11
+ it "is not implemented yet" do
12
+ request = <%= %w[client bidi].include?(rpc[:kind]) ? "[#{ruby_module}::#{rpc[:name]}Request.new]" : "#{ruby_module}::#{rpc[:name]}Request.new" %>
13
+ expect { Tonic::Rails::Testing.call(described_class, :<%= rpc[:name] %>, request) }.to raise_grpc_error(:unimplemented)
14
+ end
15
+ end
16
+ <% end -%>
17
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "tonic/rails/testing/minitest"
5
+
6
+ class <%= service_class %>Test < ActiveSupport::TestCase
7
+ include Tonic::Rails::Testing::Assertions
8
+ <% rpc_specs.each do |rpc| %>
9
+ test "<%= rpc[:name] %> is not implemented yet" do
10
+ request = <%= %w[client bidi].include?(rpc[:kind]) ? "[#{ruby_module}::#{rpc[:name]}Request.new]" : "#{ruby_module}::#{rpc[:name]}Request.new" %>
11
+ assert_grpc_error(:unimplemented) { Tonic::Rails::Testing.call(<%= service_class %>, :<%= rpc[:name] %>, request) }
12
+ end
13
+ <% end -%>
14
+ end