tonic-rails 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f526af49b4f7647168bcdf7d64131c00c63880d3372f0355d60aa7e347240856
4
- data.tar.gz: 7698d7d70a8e3c75ef3ee63ec46ce032267688e12cb86fbca35bf74e41f6f29f
3
+ metadata.gz: c1578d591791ff33ce8365ae3fe5b5a6bb5dfbac8759e4f9762c7c511e71057a
4
+ data.tar.gz: e23782ccd2cf964d5db2cf25c98547780525e20d7880f0dab85a0c4c956b45cd
5
5
  SHA512:
6
- metadata.gz: ad814eb2ab41e6009649bd5307a8f304e3f1baab030f00a25045b65ec0bd1279aa7eceac041d141932281073ce650172191e0fcfbec9c74066f5c224b64e811c
7
- data.tar.gz: b1cc149c6beb0f3bb2f99579cc5b4ef4e13964654a93969b431bf0032a25f9fa624f5888748eb0fe37fef1c13762146b881aec6d3347cbc27fe0fee468990275
6
+ metadata.gz: 616f736a4e29cd3fcf8fd4297d6159d9abb145f3973b27a08f6f2c153bf7f5ae87422870ef304dab552e62f8ef7b3037716432c89bfd5d12088ef97e1ae58166
7
+ data.tar.gz: 07a9749e9188700473883761291731a3f0b947bcdf4d986cd55df81dcb658229863149cfda1174a186a0cfe321e8e09810b7c1113232fefdc38c0ca2f1bba962
data/CHANGELOG.md CHANGED
@@ -7,6 +7,38 @@ minor versions may contain breaking changes.
7
7
 
8
8
  ## [Unreleased]
9
9
 
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
+
10
42
  ## [0.2.0] - 2026-10-11
11
43
 
12
44
  ### Added
@@ -84,6 +116,7 @@ First public release.
84
116
  - Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
85
117
  macOS (x86_64, arm64), Ruby 3.3 to 4.0.
86
118
 
87
- [Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.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
88
121
  [0.2.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...v0.2.0
89
122
  [0.1.0]: https://github.com/CodingAnarchy/tonic-rails/releases/tag/v0.1.0
data/Cargo.lock CHANGED
@@ -1023,7 +1023,7 @@ dependencies = [
1023
1023
 
1024
1024
  [[package]]
1025
1025
  name = "tonic_rails_native"
1026
- version = "0.2.0"
1026
+ version = "0.3.0"
1027
1027
  dependencies = [
1028
1028
  "axum",
1029
1029
  "bytes",
data/README.md CHANGED
@@ -60,10 +60,31 @@ On platforms without a precompiled gem, installation builds the
60
60
  extension from source, which needs a [Rust toolchain](https://rustup.rs).
61
61
 
62
62
  To generate Ruby classes from your `.proto` files you also need `protoc`
63
- (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.
64
68
 
65
69
  ## Quick start
66
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
+
67
88
  ### 1. Define the service
68
89
 
69
90
  ```protobuf
@@ -236,11 +257,54 @@ Any other exception becomes `INTERNAL` with the message `internal error`.
236
257
  The real exception is logged and passed to `Rails.error.report`, so your
237
258
  error tracker sees it.
238
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
+
239
301
  ### Metadata, deadlines and shutdown
240
302
 
241
303
  ```ruby
242
304
  def get_item(request)
243
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
244
308
  deadline_ms # ms left before the client's deadline, or nil
245
309
  deadline_exceeded? # true once it has passed
246
310
  cancelled? # true while the server shuts down
@@ -322,6 +386,30 @@ result.headers["x-cache"] # => "hit"
322
386
  result.trailers["x-cost-ms"] # => "3"
323
387
  ```
324
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
411
+ ```
412
+
325
413
  ## Configuration
326
414
 
327
415
  Set options in `config/application.rb` or an environment file:
@@ -353,6 +441,8 @@ end
353
441
  | `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
354
442
  | `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
355
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 |
356
446
  | `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
357
447
  | `reflection` | `false` | Serve `grpc.reflection.v1`; exposes your schema |
358
448
  | `interceptors` | `[]` | See [Interceptors](#interceptors) |
@@ -399,7 +489,19 @@ Don't combine it with the plugin.
399
489
  - **Health checks.** `grpc.health.v1.Health` reports `SERVING` for the
400
490
  server (`""`) and every registered service, and flips to `NOT_SERVING`
401
491
  as soon as shutdown begins. Point your load balancer or Kubernetes
402
- 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.
403
505
  - **Graceful shutdown.** On SIGTERM or SIGINT the server stops
404
506
  accepting connections, tells clients to go away (HTTP/2 GOAWAY), lets
405
507
  in-flight calls finish for up to `drain_timeout` seconds, then exits.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tonic_rails_native"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  publish = false
@@ -21,10 +21,11 @@ use magnus::{Value, value::Opaque};
21
21
  use tokio::sync::mpsc;
22
22
  use tonic::Code;
23
23
  use tonic::body::Body as TonicBody;
24
+ use tonic::transport::server::{TcpConnectInfo, TlsConnectInfo};
24
25
 
25
26
  use crate::body::{ErrorBody, ResponseBody, ServerStats};
26
27
  use crate::framing::{MessageReader, append_metadata, parse_grpc_timeout};
27
- use crate::gvl::{DispatchContext, RequestError, StreamMessage};
28
+ use crate::gvl::{DispatchContext, Peer, RequestError, StreamMessage};
28
29
  use crate::log::Level;
29
30
 
30
31
  /// Messages buffered per direction before backpressure applies.
@@ -166,6 +167,7 @@ impl DispatchInner {
166
167
  request_rx,
167
168
  request_error: request_error.clone(),
168
169
  log: self.stats.log.clone(),
170
+ peer: peer_of(&req),
169
171
  });
170
172
 
171
173
  match self.request_tx.try_send(ctx) {
@@ -241,6 +243,26 @@ impl DispatchInner {
241
243
  }
242
244
  }
243
245
 
246
+ /// The client's address and, if it presented one over TLS, its
247
+ /// certificate chain. tonic records both on the request.
248
+ fn peer_of<B>(req: &Request<B>) -> Peer {
249
+ let address = req
250
+ .extensions()
251
+ .get::<TcpConnectInfo>()
252
+ .and_then(TcpConnectInfo::remote_addr)
253
+ .map(|addr| addr.to_string());
254
+ let certificates = req
255
+ .extensions()
256
+ .get::<TlsConnectInfo<TcpConnectInfo>>()
257
+ .and_then(TlsConnectInfo::peer_certs)
258
+ .map(|chain| chain.iter().map(|der| der.to_vec()).collect())
259
+ .unwrap_or_default();
260
+ Peer {
261
+ address,
262
+ certificates,
263
+ }
264
+ }
265
+
244
266
  /// Read the request body and forward each unframed message to the
245
267
  /// handler. Dropping `tx` signals end of stream; errors are recorded
246
268
  /// in `error` first so the reader can distinguish them.
@@ -369,6 +369,15 @@ pub struct DispatchContext {
369
369
  pub request_rx: mpsc::Receiver<Vec<u8>>,
370
370
  pub request_error: Arc<RequestError>,
371
371
  pub log: LogSink,
372
+ pub peer: Peer,
373
+ }
374
+
375
+ /// The client connection: remote address and, with mutual TLS, its
376
+ /// certificate chain (DER, leaf first).
377
+ #[derive(Default)]
378
+ pub struct Peer {
379
+ pub address: Option<String>,
380
+ pub certificates: Vec<Vec<u8>>,
372
381
  }
373
382
 
374
383
  /// Bounds on how long one thread keeps the GVL to drain a busy queue.
@@ -441,6 +450,7 @@ fn run_handler(ctx: DispatchContext) {
441
450
  request_rx,
442
451
  request_error,
443
452
  log,
453
+ peer,
444
454
  } = ctx;
445
455
 
446
456
  let trailers = Arc::new(Mutex::new(Metadata::new()));
@@ -454,6 +464,7 @@ fn run_handler(ctx: DispatchContext) {
454
464
  request_rx,
455
465
  request_error,
456
466
  trailers.clone(),
467
+ &peer,
457
468
  );
458
469
  let end = StreamMessage::End {
459
470
  status: result.err().map(|e| error_status(&e, &log)),
@@ -474,6 +485,7 @@ fn call_handler(
474
485
  request_rx: mpsc::Receiver<Vec<u8>>,
475
486
  request_error: Arc<RequestError>,
476
487
  trailers: Arc<Mutex<Metadata>>,
488
+ peer: &Peer,
477
489
  ) -> Result<(), Error> {
478
490
  let md = ruby.hash_new_capa(metadata.len());
479
491
  for (k, v) in metadata {
@@ -496,6 +508,16 @@ fn call_handler(
496
508
  if let Some(ms) = timeout_ms {
497
509
  call.aset(ruby.to_symbol("timeout_ms"), ms as u64)?;
498
510
  }
511
+ if let Some(address) = &peer.address {
512
+ call.aset(ruby.to_symbol("peer_address"), ruby.str_new(address))?;
513
+ }
514
+ if !peer.certificates.is_empty() {
515
+ let certificates = ruby.ary_new_capa(peer.certificates.len());
516
+ for der in &peer.certificates {
517
+ certificates.push(ruby.str_from_slice(der))?;
518
+ }
519
+ call.aset(ruby.to_symbol("peer_certificates"), certificates)?;
520
+ }
499
521
 
500
522
  let callback = ruby.get_inner(callback);
501
523
  let result = callback.funcall::<_, _, Value>("call", (ruby.str_new(path), md, call));
@@ -71,6 +71,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
71
71
  )?;
72
72
  class.define_method("start_server", method!(NativeServer::start_server, 0))?;
73
73
  class.define_method("serve_worker", method!(NativeServer::serve_worker, 0))?;
74
+ class.define_method("set_health", method!(NativeServer::set_health, 2))?;
74
75
  class.define_method("begin_shutdown", method!(NativeServer::begin_shutdown, 0))?;
75
76
  class.define_method("wait_for_drain", method!(NativeServer::wait_for_drain, 1))?;
76
77
  class.define_method("stop_workers", method!(NativeServer::stop_workers, 0))?;
@@ -409,6 +409,27 @@ impl NativeServer {
409
409
  Ok(())
410
410
  }
411
411
 
412
+ /// Set the grpc.health.v1 status of one service ("" = the whole
413
+ /// server). Returns false if the server isn't running.
414
+ pub fn set_health(&self, service: String, serving: bool) -> bool {
415
+ let Some(r) = self
416
+ .running
417
+ .lock()
418
+ .as_ref()
419
+ .map(|r| (r.runtime.handle().clone(), r.health.clone()))
420
+ else {
421
+ return false;
422
+ };
423
+ let (handle, health) = r;
424
+ let status = if serving {
425
+ ServingStatus::Serving
426
+ } else {
427
+ ServingStatus::NotServing
428
+ };
429
+ handle.block_on(health.set_service_status(service, status));
430
+ true
431
+ }
432
+
412
433
  /// Mark everything NOT_SERVING and stop accepting new connections.
413
434
  /// Existing connections receive GOAWAY; in-flight RPCs continue.
414
435
  pub fn begin_shutdown(&self) {
@@ -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