tonic-rails 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73ed3d0d421cd1cb77193b7d5025925cbe8737022553d28f7c95bd0d7ca2242a
4
- data.tar.gz: e75d6f30c34e225b6668cdbe7f8072c22b5a5b1e53c782962f4327873cfcf7b2
3
+ metadata.gz: 3b594b6ba6699510bedb286cab5e05fc8fa3578d200fdb704fd8335529ee0105
4
+ data.tar.gz: df3ddc7f65ca300dce59a26855fcadfd88f573c9f892039afa727ec16a3dc01e
5
5
  SHA512:
6
- metadata.gz: d66dbcc4d4bd4721d007fd0ace7004fc2af5ae1c9f209c082e2004d38892fa96373c22375db9da6dbeae358d93b9a0f69c30ba7f3309f0a8bed3615dcb9c11a3
7
- data.tar.gz: 209db51bbf3305ed9aeba24a9c407653f08397b875490d295e3ebccaf3667182869ee5a950fe78a747c3b7d3799f5ae879aa9453dc594fbbd8ddedca66bb3fa9
6
+ metadata.gz: 0d8da9f21bc78c95c31b708a0349d54f23e9a5395bad4de0d46937ec1fae721bd6590a74d498dd523e087b1a1c90b23af6a10c93f2cfaab262d554e3c74272a0
7
+ data.tar.gz: a67219c39168b2eeae48a638b73ebdfa65a4c5a0300138577088f214868db989e36196647639753563584e49dfb256da0175f2b13aac0b8526d30cb9cee38f44
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/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.
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/callbacks"
4
+ require "active_support/rescuable"
3
5
  require "active_support/core_ext/class/attribute"
4
6
  require "active_support/core_ext/hash/keys"
5
7
  require "active_support/core_ext/string/inflections"
@@ -25,8 +27,17 @@ module Tonic
25
27
  # is set (fastest), or string-keyed Hashes by default. Handlers may
26
28
  # return either a protobuf message or a Hash; streaming handlers
27
29
  # return any Enumerable of them.
30
+ #
31
+ # Like controllers, handlers get callbacks and exception mapping:
32
+ #
33
+ # before_rpc :authenticate, except: :Health
34
+ # rescue_from ActiveRecord::RecordNotFound, code: :not_found
35
+ # rescue_from Pundit::NotAuthorizedError, with: :forbidden
28
36
  module Base
29
37
  def self.included(base)
38
+ base.include(ActiveSupport::Callbacks)
39
+ base.include(ActiveSupport::Rescuable)
40
+ base.define_callbacks(:rpc)
30
41
  base.extend(ClassMethods)
31
42
  base.class_attribute :grpc_service_name, instance_writer: false
32
43
  base.class_attribute :grpc_rpcs, instance_writer: false, default: {}
@@ -34,6 +45,20 @@ module Tonic
34
45
  end
35
46
 
36
47
  module ClassMethods
48
+ private
49
+
50
+ def add_rpc_callback(kind, methods, only, except, block)
51
+ only &&= rpc_name_matcher(only)
52
+ except &&= rpc_name_matcher(except)
53
+ condition = lambda do |handler|
54
+ name = handler.current_rpc.underscore
55
+ (only.nil? || only.include?(name)) && (except.nil? || !except.include?(name))
56
+ end
57
+ (methods + [block].compact).each { |callback| set_callback(:rpc, kind, callback, if: condition) }
58
+ end
59
+
60
+ public
61
+
37
62
  # @param name [String] fully-qualified service name, e.g. "shop.ProductService"
38
63
  def service(name)
39
64
  self.grpc_service_name = name.to_s
@@ -97,6 +122,51 @@ module Tonic
97
122
  end
98
123
  end
99
124
 
125
+ # Run a method or block before each RPC. Raising (e.g. an RpcError)
126
+ # stops the call. The decoded `request` is available.
127
+ # @param only [Symbol, String, Array] RPC names to apply to
128
+ # @param except [Symbol, String, Array] RPC names to skip
129
+ def before_rpc(*methods, only: nil, except: nil, &block)
130
+ add_rpc_callback(:before, methods, only, except, block)
131
+ end
132
+
133
+ # Run after each RPC that completes without raising (for streaming
134
+ # responses, after the last message is sent).
135
+ def after_rpc(*methods, only: nil, except: nil, &block)
136
+ add_rpc_callback(:after, methods, only, except, block)
137
+ end
138
+
139
+ # Wrap each RPC; the method or block must yield to run it.
140
+ def around_rpc(*methods, only: nil, except: nil, &block)
141
+ add_rpc_callback(:around, methods, only, except, block)
142
+ end
143
+
144
+ # Map exceptions raised during an RPC (including callbacks) to a
145
+ # gRPC status. Same as ActiveSupport::Rescuable, plus a `code:`
146
+ # shorthand:
147
+ #
148
+ # rescue_from ActiveRecord::RecordNotFound, code: :not_found
149
+ # rescue_from Stale, code: :aborted, message: "please retry"
150
+ # rescue_from Denied, with: :deny # def deny(e) = raise RpcError...
151
+ #
152
+ # With `code:`, the client receives `message` (a String, or a Proc
153
+ # given the exception), defaulting to the exception's own message.
154
+ # Handlers given via `with:` or a block must raise an RpcError.
155
+ def rescue_from(*klasses, with: nil, code: nil, message: nil, &)
156
+ return super(*klasses, with: with, &) unless code
157
+
158
+ RpcError.normalize_code(code) # fail at definition time on typos
159
+ super(*klasses) do |exception|
160
+ text = message.respond_to?(:call) ? message.call(exception) : (message || exception.message)
161
+ raise RpcError.new(text, code: code)
162
+ end
163
+ end
164
+
165
+ # @api private
166
+ def rpc_name_matcher(names)
167
+ Array(names).map { |name| name.to_s.underscore }
168
+ end
169
+
100
170
  # @api private Called by Server#register.
101
171
  def register_on(router)
102
172
  raise NotConfiguredError, "#{self} has no service name; call `service` or `rpc_service`" unless grpc_service_name
@@ -106,6 +176,18 @@ module Tonic
106
176
  end
107
177
  end
108
178
 
179
+ # The RPC being handled, as named in the .proto (e.g. "GetItem").
180
+ # @return [String]
181
+ attr_reader :current_rpc
182
+
183
+ # The client: address, and certificate with mutual TLS.
184
+ # @return [Tonic::Rails::Peer]
185
+ attr_reader :peer
186
+
187
+ # The decoded request: a message (or Hash), or for client-streaming
188
+ # RPCs an Enumerator of them. Also passed to the handler method.
189
+ attr_reader :request
190
+
109
191
  # gRPC request metadata (lower-case header names). Binary (`-bin`)
110
192
  # values are base64-encoded, as sent on the wire.
111
193
  # @return [Hash{String=>String}]
@@ -152,29 +234,46 @@ module Tonic
152
234
  # (`#push`) for server-streaming RPCs.
153
235
  # @return [String, nil] the encoded unary response, or nil when
154
236
  # messages were streamed through `sender`
155
- def call_rpc(rpc_name, metadata:, reader:, sender: nil, timeout_ms: nil)
237
+ def call_rpc(rpc_name, metadata:, reader:, sender: nil, timeout_ms: nil, peer: nil)
156
238
  spec = self.class.grpc_rpcs[rpc_name.to_s]
157
239
  raise RpcError.new("method not implemented", code: :unimplemented) unless spec
158
240
  raise RpcError.new("method #{rpc_name} not implemented", code: :unimplemented) unless respond_to?(spec[:handler])
159
241
 
242
+ @current_rpc = rpc_name.to_s
243
+ @peer = peer || Peer.new
160
244
  @grpc_metadata = metadata
161
245
  @grpc_sender = sender
162
246
  @grpc_headers_sent = false
163
247
  @grpc_deadline_at = timeout_ms && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + (timeout_ms / 1000.0))
164
248
 
165
- result = public_send(spec[:handler], build_request(reader, spec))
166
- respond(result, spec, sender)
249
+ @request = build_request(reader, spec)
250
+ response = nil
251
+ run_callbacks(:rpc) { response = respond(public_send(spec[:handler], @request), spec, sender) }
252
+ response
167
253
  rescue RpcError
168
254
  raise
169
255
  rescue StandardError => e
170
- Tonic::Rails.log_exception(e, "#{self.class}##{rpc_name}")
171
- raise RpcError.new("internal error", code: :internal)
256
+ rescue_exception(e)
172
257
  ensure
173
258
  attach_trailing_metadata
174
259
  end
175
260
 
176
261
  private
177
262
 
263
+ # rescue_from handlers must raise; anything they don't handle (or a
264
+ # handler that returns normally) becomes a logged INTERNAL error.
265
+ def rescue_exception(exception)
266
+ if rescue_with_handler(exception)
267
+ Tonic::Rails.log_exception(
268
+ TypeError.new("rescue_from handler for #{exception.class} returned instead of raising an RpcError"),
269
+ "#{self.class}##{current_rpc}",
270
+ )
271
+ else
272
+ Tonic::Rails.log_exception(exception, "#{self.class}##{current_rpc}")
273
+ end
274
+ raise RpcError.new("internal error", code: :internal)
275
+ end
276
+
178
277
  def build_request(reader, spec)
179
278
  unless spec[:request_stream]
180
279
  return read_request(reader, spec) || raise(RpcError.new("missing request message", code: :invalid_argument))
@@ -69,6 +69,17 @@ module Tonic
69
69
  # HTTP/2 PING interval and ack timeout, in seconds. Defaults 15 / 5.
70
70
  attr_accessor :keepalive_interval, :keepalive_timeout
71
71
 
72
+ # Callable checked every `health_check_interval` seconds (inside the
73
+ # Rails executor). While it returns false or raises, grpc.health.v1
74
+ # reports NOT_SERVING so readiness probes and load balancers route
75
+ # around this instance. Default nil (always SERVING while up).
76
+ #
77
+ # config.health_check = -> { ActiveRecord::Base.connection.active? }
78
+ attr_accessor :health_check
79
+
80
+ # Seconds between health checks. Default 5.
81
+ attr_accessor :health_check_interval
82
+
72
83
  # Look handler classes up again on each request so code reloading
73
84
  # works. The Railtie enables this when Rails reloading is on.
74
85
  attr_accessor :reload_handlers
@@ -94,6 +105,8 @@ module Tonic
94
105
  @keepalive_interval = 15
95
106
  @keepalive_timeout = 5
96
107
  @reload_handlers = false
108
+ @health_check = nil
109
+ @health_check_interval = 5
97
110
  end
98
111
 
99
112
  def tls?
@@ -113,15 +126,24 @@ module Tonic
113
126
  raise ArgumentError, "max_message_size must be at most 4 GiB" if max_message_size > 0xFFFF_FFFF
114
127
  raise ArgumentError, "tls_cert_path and tls_key_path must be set together" if tls_cert_path.nil? != tls_key_path.nil?
115
128
  raise ArgumentError, "tls_client_ca_path requires tls_cert_path and tls_key_path" if tls_client_ca_path && !tls?
116
- unless interceptors.respond_to?(:each) && interceptors.all? { |i| i.respond_to?(:call) }
117
- raise ArgumentError, "interceptors must be an Array of callables"
118
- end
129
+
130
+ validate_callables!
119
131
 
120
132
  self
121
133
  end
122
134
 
123
135
  private
124
136
 
137
+ def validate_callables!
138
+ raise ArgumentError, "health_check must respond to #call" if health_check && !health_check.respond_to?(:call)
139
+ unless health_check_interval.is_a?(Numeric) && health_check_interval.positive?
140
+ raise ArgumentError, "health_check_interval must be a positive number, got #{health_check_interval.inspect}"
141
+ end
142
+ return if interceptors.respond_to?(:each) && interceptors.all? { |i| i.respond_to?(:call) }
143
+
144
+ raise ArgumentError, "interceptors must be an Array of callables"
145
+ end
146
+
125
147
  def positive_integer!(name)
126
148
  value = public_send(name)
127
149
  return if value.is_a?(Integer) && value.positive?
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # What grpc.health.v1 reports, per service ("" = the whole server).
6
+ #
7
+ # A service reports SERVING only while the app hasn't marked it
8
+ # NOT_SERVING (Server#not_serving!) *and* the optional
9
+ # `config.health_check` passes. Neither overrides the other.
10
+ # @api private
11
+ class Health
12
+ def initialize(configuration, router)
13
+ @configuration = configuration
14
+ @router = router
15
+ @marks = {}
16
+ @check_passing = true
17
+ @native = nil
18
+ @checker = nil
19
+ end
20
+
21
+ def mark(services, serving)
22
+ names = services.empty? ? names_to_report : services.map(&:to_s)
23
+ names.each { |name| @marks[name] = serving }
24
+ publish(names)
25
+ end
26
+
27
+ # Start reporting through a running native server.
28
+ def attach(native)
29
+ @native = native
30
+ @check_passing = true
31
+ publish(names_to_report)
32
+ start_checker
33
+ end
34
+
35
+ # Stop the checker before shutdown flips everything NOT_SERVING, so
36
+ # a passing check can't flip it back.
37
+ def detach
38
+ stop_checker
39
+ @native = nil
40
+ end
41
+
42
+ private
43
+
44
+ def names_to_report
45
+ ["", *@router.service_names, *@marks.keys].uniq
46
+ end
47
+
48
+ def publish(names)
49
+ native = @native
50
+ return unless native
51
+
52
+ names.each { |name| native.set_health(name, @check_passing && @marks.fetch(name, true)) }
53
+ end
54
+
55
+ def start_checker
56
+ check = @configuration.health_check
57
+ return unless check
58
+
59
+ @wakeup = Thread::Queue.new
60
+ @checker = Thread.new do
61
+ Thread.current.name = "tonic-rails-health"
62
+ loop do
63
+ passing = run_check(check)
64
+ changed(passing) if passing != @check_passing
65
+ break if @wakeup.pop(timeout: @configuration.health_check_interval)
66
+ end
67
+ end
68
+ end
69
+
70
+ def stop_checker
71
+ return unless @checker
72
+
73
+ @wakeup << :stop
74
+ @checker.join(@configuration.drain_timeout.to_f + 5)
75
+ @checker = nil
76
+ end
77
+
78
+ def changed(passing)
79
+ @check_passing = passing
80
+ publish(names_to_report)
81
+ logger = @configuration.logger
82
+ return unless logger
83
+
84
+ if passing
85
+ logger.info("tonic-rails: health check passing; SERVING")
86
+ else
87
+ logger.warn("tonic-rails: health check failing; NOT_SERVING")
88
+ end
89
+ end
90
+
91
+ # Runs inside the Rails executor so database connections are returned.
92
+ def run_check(check)
93
+ result = if defined?(::Rails.application) && ::Rails.application
94
+ ::Rails.application.executor.wrap { check.call }
95
+ else
96
+ check.call
97
+ end
98
+ result ? true : false
99
+ rescue StandardError => e
100
+ Tonic::Rails.log_exception(e, "health check")
101
+ false
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ # The client on the other end of an RPC, available in handlers as
6
+ # `peer`:
7
+ #
8
+ # peer.address # => "10.0.4.17:51234"
9
+ # peer.ip # => "10.0.4.17"
10
+ # peer.certificate # => OpenSSL::X509::Certificate, with mutual TLS
11
+ # peer.certificate&.subject.to_s # => "/CN=billing-service"
12
+ class Peer
13
+ # @return [String, nil] "ip:port" ("[ipv6]:port" for IPv6)
14
+ attr_reader :address
15
+
16
+ # @param address [String, nil]
17
+ # @param certificates [Array<String>] DER-encoded chain, leaf first
18
+ def initialize(address = nil, certificates = [])
19
+ @address = address
20
+ @certificate_ders = certificates
21
+ end
22
+
23
+ # @return [String, nil]
24
+ def ip
25
+ return unless address
26
+
27
+ host = address.rpartition(":").first
28
+ host.delete_prefix("[").delete_suffix("]")
29
+ end
30
+
31
+ # @return [Integer, nil]
32
+ def port
33
+ address && Integer(address.rpartition(":").last, exception: false)
34
+ end
35
+
36
+ # The client certificate presented over mutual TLS, or nil.
37
+ # @return [OpenSSL::X509::Certificate, nil]
38
+ def certificate
39
+ certificate_chain.first
40
+ end
41
+
42
+ # @return [Array<OpenSSL::X509::Certificate>] leaf first
43
+ def certificate_chain
44
+ @certificate_chain ||= begin
45
+ require "openssl"
46
+ @certificate_ders.map { |der| OpenSSL::X509::Certificate.new(der) }
47
+ end
48
+ end
49
+
50
+ # True when the client authenticated with a certificate.
51
+ def authenticated?
52
+ !@certificate_ders.empty?
53
+ end
54
+
55
+ def inspect
56
+ "#<#{self.class.name} #{address || 'unknown'}#{' (certificate)' if authenticated?}>"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -38,6 +38,7 @@ module Tonic
38
38
  reader: call[:reader],
39
39
  sender: call[:sender],
40
40
  timeout_ms: call[:timeout_ms],
41
+ peer: Peer.new(call[:peer_address], call[:peer_certificates] || []),
41
42
  )
42
43
  end
43
44
 
@@ -22,6 +22,7 @@ module Tonic
22
22
  @state = :stopped
23
23
  @stop_signal = nil
24
24
  @stop_requested = false
25
+ @health = Health.new(configuration, @router)
25
26
  @lock = Mutex.new
26
27
  end
27
28
 
@@ -58,6 +59,7 @@ module Tonic
58
59
  @native = build_native_server
59
60
  @native.start_server
60
61
  log_pump = spawn_log_pump
62
+ @health.attach(@native)
61
63
  @state = :running
62
64
 
63
65
  request_log = subscribe_request_logger
@@ -67,10 +69,12 @@ module Tonic
67
69
  yield self if block_given?
68
70
 
69
71
  wait_for_stop(workers)
72
+ @health.detach
70
73
  shutdown(workers)
71
74
  ensure
72
75
  ActiveSupport::Notifications.unsubscribe(request_log) if request_log
73
76
  previous_handlers&.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
77
+ @health.detach
74
78
  @native&.stop_server
75
79
  stop_log_pump(log_pump)
76
80
  @native = nil
@@ -91,6 +95,25 @@ module Tonic
91
95
  @state == :running
92
96
  end
93
97
 
98
+ # Mark the whole server, or the given services (fully-qualified
99
+ # names), SERVING in grpc.health.v1. Takes effect immediately when
100
+ # running, otherwise when the server starts.
101
+ #
102
+ # With `config.health_check`, a service reports SERVING only while the
103
+ # check passes *and* it's marked serving here.
104
+ def serving!(*services)
105
+ @health.mark(services, true)
106
+ self
107
+ end
108
+
109
+ # Mark the whole server, or the given services, NOT_SERVING (e.g. for
110
+ # maintenance), so load balancers and readiness probes route around
111
+ # this instance. Calls are still served.
112
+ def not_serving!(*services)
113
+ @health.mark(services, false)
114
+ self
115
+ end
116
+
94
117
  # @return [Hash{Symbol=>Integer}] request and connection counters
95
118
  def stats
96
119
  native = @native
@@ -27,7 +27,19 @@ namespace :tonic_rails do
27
27
  ruby_out = ENV.fetch("RUBY_OUT", "lib/grpc")
28
28
  protos = Dir.glob(File.join(proto_dir, "**", "*.proto"))
29
29
  abort "No .proto files found in #{proto_dir}/" if protos.empty?
30
- abort "protoc not found on PATH. Install the Protocol Buffers compiler." unless system("protoc", "--version", out: File::NULL)
30
+ version = begin
31
+ IO.popen(%w[protoc --version], err: File::NULL, &:read)[/\d+(\.\d+)+/]
32
+ rescue SystemCallError
33
+ nil
34
+ end
35
+ abort "protoc not found on PATH. Install the Protocol Buffers compiler (22.0 or newer)." unless version
36
+ # protoc before 22 emits the Ruby DSL that google-protobuf 4 removed
37
+ # (`undefined method 'build' for DescriptorPool` at load time).
38
+ major = version.split(".").then { |parts| parts.first == "3" ? parts[1].to_i : parts.first.to_i }
39
+ if major < 22
40
+ abort "protoc #{version} is too old for google-protobuf 4: install protoc 22.0 or newer " \
41
+ "(distribution packages are often older; see https://github.com/protocolbuffers/protobuf/releases)."
42
+ end
31
43
 
32
44
  FileUtils.mkdir_p(ruby_out)
33
45
  sh "protoc", "--ruby_out=#{ruby_out}", "-I#{proto_dir}", *protos
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tonic
4
+ module Rails
5
+ module Testing
6
+ # Shared helpers for the RSpec matcher and Minitest assertions.
7
+ # @api private
8
+ module Errors
9
+ module_function
10
+
11
+ # Exceptions that carry a gRPC status: tonic-rails' own, and the
12
+ # official grpc gem client's, when it's loaded.
13
+ def classes
14
+ [Tonic::Rails::RpcError, (GRPC::BadStatus if defined?(GRPC::BadStatus))].compact
15
+ end
16
+
17
+ def status?(error)
18
+ classes.any? { |klass| error.is_a?(klass) }
19
+ end
20
+
21
+ # @return [Integer]
22
+ def code(error)
23
+ error.code
24
+ end
25
+
26
+ # @return [String] the status message the client sees. The grpc
27
+ # gem keeps it in #details (its #message adds the code).
28
+ def message(error)
29
+ text = defined?(GRPC::BadStatus) && error.is_a?(GRPC::BadStatus) ? error.details.to_s : error.message
30
+ text.dup.force_encoding(Encoding::UTF_8)
31
+ end
32
+
33
+ def code_name(code)
34
+ Tonic::Rails::RpcError::CODE_NAMES.fetch(code, code)
35
+ end
36
+
37
+ def message_matches?(expected, actual)
38
+ expected.is_a?(Regexp) ? expected.match?(actual) : expected.to_s == actual
39
+ end
40
+
41
+ def describe(error)
42
+ "#{code_name(code(error)).to_s.upcase} (#{message(error).inspect})"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tonic/rails/testing"
4
+ require "tonic/rails/testing/errors"
5
+
6
+ module Tonic
7
+ module Rails
8
+ module Testing
9
+ # Minitest assertions for gRPC statuses:
10
+ #
11
+ # class ItemServiceTest < ActiveSupport::TestCase
12
+ # include Tonic::Rails::Testing::Assertions
13
+ #
14
+ # test "missing items" do
15
+ # error = assert_grpc_error(:not_found) { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
16
+ # assert_match(/0/, error.message)
17
+ # end
18
+ # end
19
+ module Assertions
20
+ # Asserts the block raises a gRPC status (Tonic::Rails::RpcError or
21
+ # GRPC::BadStatus) with the given code and, optionally, message
22
+ # (String or Regexp). Returns the error.
23
+ def assert_grpc_error(code = nil, message = nil, &)
24
+ errors = Testing::Errors
25
+ error = assert_raises(*errors.classes, &)
26
+ if code
27
+ expected = Tonic::Rails::RpcError.normalize_code(code)
28
+ assert_equal(
29
+ errors.code_name(expected), errors.code_name(errors.code(error)),
30
+ "Expected gRPC status #{errors.code_name(expected).to_s.upcase}, got #{errors.describe(error)}"
31
+ )
32
+ end
33
+ if message
34
+ assert(
35
+ errors.message_matches?(message, errors.message(error)),
36
+ "Expected gRPC message #{message.inspect}, got #{errors.message(error).inspect}",
37
+ )
38
+ end
39
+ error
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+ require "tonic/rails/testing"
5
+ require "tonic/rails/testing/errors"
6
+
7
+ # Matches a block that ends with a gRPC status, raised as a
8
+ # Tonic::Rails::RpcError (Testing.call) or a GRPC::BadStatus (the official
9
+ # client):
10
+ #
11
+ # expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
12
+ # .to raise_grpc_error(:not_found)
13
+ # expect { stub.get_item(request) }.to raise_grpc_error(:invalid_argument).with_message(/name/)
14
+ # expect { ... }.to raise_grpc_error # any non-OK status
15
+ RSpec::Matchers.define :raise_grpc_error do |expected_code = nil|
16
+ errors = Tonic::Rails::Testing::Errors
17
+
18
+ supports_block_expectations
19
+
20
+ chain(:with_message) { |message| @expected_message = message }
21
+
22
+ match do |block|
23
+ @expected = expected_code && Tonic::Rails::RpcError.normalize_code(expected_code)
24
+ begin
25
+ block.call
26
+ @error = nil
27
+ rescue Exception => e # rubocop:disable Lint/RescueException
28
+ raise unless errors.status?(e)
29
+
30
+ @error = e
31
+ end
32
+ return false unless @error
33
+
34
+ (@expected.nil? || errors.code(@error) == @expected) &&
35
+ (!defined?(@expected_message) || errors.message_matches?(@expected_message, errors.message(@error)))
36
+ end
37
+
38
+ description do
39
+ text = +"raise a gRPC error"
40
+ text << " #{errors.code_name(@expected).to_s.upcase}" if @expected
41
+ text << " with message #{@expected_message.inspect}" if defined?(@expected_message)
42
+ text
43
+ end
44
+
45
+ failure_message do
46
+ actual = @error ? "got #{errors.describe(@error)}" : "nothing was raised"
47
+ "expected block to #{description}, but #{actual}"
48
+ end
49
+
50
+ failure_message_when_negated do
51
+ "expected block not to #{description}, but got #{errors.describe(@error)}"
52
+ end
53
+ end
@@ -65,11 +65,12 @@ module Tonic
65
65
  # request; an Array of them for client- and bidi-streaming RPCs
66
66
  # @param metadata [Hash{String=>String}]
67
67
  # @param timeout_ms [Integer, nil] simulated client deadline
68
+ # @param peer [Tonic::Rails::Peer, nil] simulated client (address, certificates)
68
69
  # @return [Google::Protobuf::AbstractMessage, Array] the response, or
69
70
  # an Array of responses for server- and bidi-streaming RPCs
70
71
  # @raise [RpcError] when the handler ends the call with a status
71
- def call(service_class, rpc_name, request, metadata: {}, timeout_ms: nil)
72
- invoke(service_class, rpc_name, request, metadata: metadata, timeout_ms: timeout_ms).response
72
+ def call(service_class, rpc_name, request, metadata: {}, timeout_ms: nil, peer: nil)
73
+ invoke(service_class, rpc_name, request, metadata: metadata, timeout_ms: timeout_ms, peer: peer).response
73
74
  end
74
75
 
75
76
  # Like #call, but returns a Result with the response metadata too:
@@ -80,7 +81,7 @@ module Tonic
80
81
  # result.trailers["x-cost-ms"] # => "3"
81
82
  #
82
83
  # @return [Result]
83
- def invoke(service_class, rpc_name, request, metadata: {}, timeout_ms: nil)
84
+ def invoke(service_class, rpc_name, request, metadata: {}, timeout_ms: nil, peer: nil)
84
85
  spec = service_class.grpc_rpcs.fetch(rpc_name.to_s) do
85
86
  raise ArgumentError, "#{service_class} has no RPC #{rpc_name}"
86
87
  end
@@ -95,6 +96,7 @@ module Tonic
95
96
  reader: reader,
96
97
  sender: sender,
97
98
  timeout_ms: timeout_ms,
99
+ peer: peer,
98
100
  )
99
101
 
100
102
  response = if spec[:response_stream]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Tonic
4
4
  module Rails
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
data/lib/tonic-rails.rb CHANGED
@@ -11,11 +11,13 @@ end
11
11
 
12
12
  require "tonic/rails/version"
13
13
  require "tonic/rails/metadata"
14
+ require "tonic/rails/peer"
14
15
  require "tonic/rails/rpc_error"
15
16
  require "tonic/rails/configuration"
16
17
  require "tonic/rails/router"
17
18
  require "tonic/rails/instrumentation"
18
19
  require "tonic/rails/base"
20
+ require "tonic/rails/health"
19
21
  require "tonic/rails/reflection"
20
22
  require "tonic/rails/server"
21
23
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tonic-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: x86_64-linux-gnu
6
6
  authors:
7
7
  - Matt Tanous
@@ -54,6 +54,13 @@ files:
54
54
  - CHANGELOG.md
55
55
  - LICENSE
56
56
  - README.md
57
+ - lib/generators/tonic_rails/install/install_generator.rb
58
+ - lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt
59
+ - lib/generators/tonic_rails/service/service_generator.rb
60
+ - lib/generators/tonic_rails/service/templates/handler.rb.tt
61
+ - lib/generators/tonic_rails/service/templates/service.proto.tt
62
+ - lib/generators/tonic_rails/service/templates/service_spec.rb.tt
63
+ - lib/generators/tonic_rails/service/templates/service_test.rb.tt
57
64
  - lib/puma/plugin/tonic_rails.rb
58
65
  - lib/tonic-rails.rb
59
66
  - lib/tonic/rails/3.3/tonic_rails_native.so
@@ -61,8 +68,10 @@ files:
61
68
  - lib/tonic/rails/4.0/tonic_rails_native.so
62
69
  - lib/tonic/rails/base.rb
63
70
  - lib/tonic/rails/configuration.rb
71
+ - lib/tonic/rails/health.rb
64
72
  - lib/tonic/rails/instrumentation.rb
65
73
  - lib/tonic/rails/metadata.rb
74
+ - lib/tonic/rails/peer.rb
66
75
  - lib/tonic/rails/railtie.rb
67
76
  - lib/tonic/rails/reflection.rb
68
77
  - lib/tonic/rails/router.rb
@@ -70,6 +79,9 @@ files:
70
79
  - lib/tonic/rails/server.rb
71
80
  - lib/tonic/rails/tasks/tonic-rails.rake
72
81
  - lib/tonic/rails/testing.rb
82
+ - lib/tonic/rails/testing/errors.rb
83
+ - lib/tonic/rails/testing/minitest.rb
84
+ - lib/tonic/rails/testing/rspec.rb
73
85
  - lib/tonic/rails/version.rb
74
86
  homepage: https://github.com/CodingAnarchy/tonic-rails
75
87
  licenses: