tonic-rails 0.2.0-aarch64-linux-gnu → 0.3.0-aarch64-linux-gnu
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -1
- data/README.md +104 -2
- data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
- data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
- data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
- data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
- data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
- data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
- data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
- data/lib/tonic/rails/3.3/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/3.4/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/4.0/tonic_rails_native.so +0 -0
- data/lib/tonic/rails/base.rb +104 -5
- data/lib/tonic/rails/configuration.rb +25 -3
- data/lib/tonic/rails/health.rb +105 -0
- data/lib/tonic/rails/peer.rb +60 -0
- data/lib/tonic/rails/router.rb +1 -0
- data/lib/tonic/rails/server.rb +23 -0
- data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
- data/lib/tonic/rails/testing/errors.rb +47 -0
- data/lib/tonic/rails/testing/minitest.rb +44 -0
- data/lib/tonic/rails/testing/rspec.rb +53 -0
- data/lib/tonic/rails/testing.rb +5 -3
- data/lib/tonic/rails/version.rb +1 -1
- data/lib/tonic-rails.rb +2 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0cd959f610ed733aa69ee1f52681c13806034c5d4616518330a9099a06b8fc67
|
|
4
|
+
data.tar.gz: 82df7fc13c7b139faebd96d20af68578e960fe6a294358b1cbca9bc7212f6b76
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: eece269128b405c5b950230e6d7ff23120bf7804ada06a643d8e1bd35d6846c4acb27fd253a0d7c876f7c93fae168b77977155f12428171935b601c2b142d574
|
|
7
|
+
data.tar.gz: 02f60f7efd124eaf131e61c6f7ffde60c68efa6031d5c7f2416242390b0c4339aa4bb54abfc1a3e8a7a1aeffd05714a281650f339d9b44e5c35660db0ecc7eea
|
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.
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/tonic/rails/base.rb
CHANGED
|
@@ -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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
data/lib/tonic/rails/router.rb
CHANGED
data/lib/tonic/rails/server.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/tonic/rails/testing.rb
CHANGED
|
@@ -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]
|
data/lib/tonic/rails/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: aarch64-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:
|