tonic-rails 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +44 -1
- data/Cargo.lock +35 -1
- data/README.md +145 -2
- data/ext/tonic_rails_native/Cargo.toml +3 -1
- data/ext/tonic_rails_native/src/dispatch.rs +23 -1
- data/ext/tonic_rails_native/src/gvl.rs +22 -0
- data/ext/tonic_rails_native/src/lib.rs +2 -0
- data/ext/tonic_rails_native/src/server.rs +121 -1
- 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/base.rb +104 -5
- data/lib/tonic/rails/configuration.rb +58 -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 +50 -5
- 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: df7cc56c6ca7ca2594bc38ab5e676d9c68f6dbcd6464155d14b3d7a83ef98ef2
|
|
4
|
+
data.tar.gz: 9763e38411184f3c9a796f9e72bf9238ae3e20c16c7687cefa3792192cba7516
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d2bf2cad63a78055da0874e0a934a44f41a015c9dd98d2512b1b8ef8b6830cfdb64d262d65c55fad5b532574ec6b7a63f4777f79b6f13e7d23dd06808417c46c
|
|
7
|
+
data.tar.gz: 04df783ef65a2eb4d8a717d4643e4e406a0040a053289d5c2841014c6bb69ec267223e2bf52b9323dc1314b179969e7bb6da2606fee8bf4f93121b2932f5ce2a
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,47 @@ minor versions may contain breaking changes.
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- gRPC-Web: `config.grpc_web = true` serves browser clients (unary and
|
|
15
|
+
server-streaming RPCs, binary and text encodings) without a proxy, on the
|
|
16
|
+
same port as native gRPC, with CORS via `grpc_web_origins`,
|
|
17
|
+
`grpc_web_allowed_headers` and `grpc_web_exposed_headers`. Closes #22.
|
|
18
|
+
|
|
19
|
+
## [0.3.0] - 2026-10-11
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Generators: `rails g tonic_rails:install` (initializer, proto and
|
|
24
|
+
generated-code directories, Puma plugin) and `rails g tonic_rails:service
|
|
25
|
+
Item GetItem ListItems:server` (proto, handler with a stub per RPC,
|
|
26
|
+
registration, and an RSpec or Minitest test).
|
|
27
|
+
- Controller-style callbacks for handlers: `before_rpc`, `after_rpc` and
|
|
28
|
+
`around_rpc` with `only:` / `except:`, plus `current_rpc` and `request`
|
|
29
|
+
accessors.
|
|
30
|
+
- `rescue_from` (ActiveSupport::Rescuable) with a `code:` shorthand that maps
|
|
31
|
+
an exception class to a gRPC status.
|
|
32
|
+
- `peer` in handlers: the client's address and, with mutual TLS, its
|
|
33
|
+
certificate (`peer.certificate`, an `OpenSSL::X509::Certificate`).
|
|
34
|
+
`Testing.call`/`invoke` accept `peer:`.
|
|
35
|
+
- App-controlled health: `config.health_check` (polled every
|
|
36
|
+
`health_check_interval` seconds inside the Rails executor) and
|
|
37
|
+
`Tonic::Rails.server.serving!` / `not_serving!` for the whole server or
|
|
38
|
+
individual services, so readiness probes reflect real dependency health.
|
|
39
|
+
- Test helpers: the `raise_grpc_error(code).with_message(...)` RSpec matcher
|
|
40
|
+
(`require "tonic/rails/testing/rspec"`) and `assert_grpc_error` for Minitest
|
|
41
|
+
(`Tonic::Rails::Testing::Assertions`). Both accept `Tonic::Rails::RpcError`
|
|
42
|
+
and the grpc gem's `GRPC::BadStatus`.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- `tonic_rails:compile_protos` requires protoc 22 or newer and says so,
|
|
47
|
+
instead of generating code google-protobuf 4 can't load (older protoc,
|
|
48
|
+
such as Ubuntu's 3.21 package, emits a Ruby DSL that google-protobuf 4
|
|
49
|
+
removed).
|
|
50
|
+
|
|
10
51
|
## [0.2.0] - 2026-10-11
|
|
11
52
|
|
|
12
53
|
### Added
|
|
@@ -84,6 +125,8 @@ First public release.
|
|
|
84
125
|
- Precompiled native gems for Linux (x86_64, aarch64; glibc and musl) and
|
|
85
126
|
macOS (x86_64, arm64), Ruby 3.3 to 4.0.
|
|
86
127
|
|
|
87
|
-
[Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.
|
|
128
|
+
[Unreleased]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.4.0...HEAD
|
|
129
|
+
[0.4.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.3.0...v0.4.0
|
|
130
|
+
[0.3.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.2.0...v0.3.0
|
|
88
131
|
[0.2.0]: https://github.com/CodingAnarchy/tonic-rails/compare/v0.1.0...v0.2.0
|
|
89
132
|
[0.1.0]: https://github.com/CodingAnarchy/tonic-rails/releases/tag/v0.1.0
|
data/Cargo.lock
CHANGED
|
@@ -1021,9 +1021,27 @@ dependencies = [
|
|
|
1021
1021
|
"tonic-prost",
|
|
1022
1022
|
]
|
|
1023
1023
|
|
|
1024
|
+
[[package]]
|
|
1025
|
+
name = "tonic-web"
|
|
1026
|
+
version = "0.14.6"
|
|
1027
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1028
|
+
checksum = "b5e6a1b6319ca4b61a4c0f0c94d439c8f3ed344cca56fe0df40e1fe4be11380b"
|
|
1029
|
+
dependencies = [
|
|
1030
|
+
"base64",
|
|
1031
|
+
"bytes",
|
|
1032
|
+
"http",
|
|
1033
|
+
"http-body",
|
|
1034
|
+
"pin-project",
|
|
1035
|
+
"tokio-stream",
|
|
1036
|
+
"tonic",
|
|
1037
|
+
"tower-layer",
|
|
1038
|
+
"tower-service",
|
|
1039
|
+
"tracing",
|
|
1040
|
+
]
|
|
1041
|
+
|
|
1024
1042
|
[[package]]
|
|
1025
1043
|
name = "tonic_rails_native"
|
|
1026
|
-
version = "0.
|
|
1044
|
+
version = "0.4.0"
|
|
1027
1045
|
dependencies = [
|
|
1028
1046
|
"axum",
|
|
1029
1047
|
"bytes",
|
|
@@ -1043,7 +1061,9 @@ dependencies = [
|
|
|
1043
1061
|
"tonic",
|
|
1044
1062
|
"tonic-health",
|
|
1045
1063
|
"tonic-reflection",
|
|
1064
|
+
"tonic-web",
|
|
1046
1065
|
"tower",
|
|
1066
|
+
"tower-http",
|
|
1047
1067
|
]
|
|
1048
1068
|
|
|
1049
1069
|
[[package]]
|
|
@@ -1065,6 +1085,20 @@ dependencies = [
|
|
|
1065
1085
|
"tracing",
|
|
1066
1086
|
]
|
|
1067
1087
|
|
|
1088
|
+
[[package]]
|
|
1089
|
+
name = "tower-http"
|
|
1090
|
+
version = "0.6.11"
|
|
1091
|
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
1092
|
+
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
|
1093
|
+
dependencies = [
|
|
1094
|
+
"bitflags",
|
|
1095
|
+
"bytes",
|
|
1096
|
+
"http",
|
|
1097
|
+
"pin-project-lite",
|
|
1098
|
+
"tower-layer",
|
|
1099
|
+
"tower-service",
|
|
1100
|
+
]
|
|
1101
|
+
|
|
1068
1102
|
[[package]]
|
|
1069
1103
|
name = "tower-layer"
|
|
1070
1104
|
version = "0.3.3"
|
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
|
|
@@ -299,6 +363,44 @@ Tonic::Rails.configure do |config|
|
|
|
299
363
|
end
|
|
300
364
|
```
|
|
301
365
|
|
|
366
|
+
## Browser clients (gRPC-Web)
|
|
367
|
+
|
|
368
|
+
Browsers can't speak native gRPC, but they can speak
|
|
369
|
+
[gRPC-Web](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md).
|
|
370
|
+
Turn it on and your existing handlers serve web frontends directly, with
|
|
371
|
+
no Envoy or other proxy:
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
Tonic::Rails.configure do |config|
|
|
375
|
+
config.grpc_web = true
|
|
376
|
+
config.grpc_web_origins = ["https://app.example.com"] # CORS; omit for same-origin only
|
|
377
|
+
config.grpc_web_exposed_headers = %w[x-request-id] # response metadata browsers may read
|
|
378
|
+
end
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Then call it from the browser with any gRPC-Web client, for example
|
|
382
|
+
[Connect](https://connectrpc.com/docs/web/using-clients):
|
|
383
|
+
|
|
384
|
+
```ts
|
|
385
|
+
import { createClient } from "@connectrpc/connect";
|
|
386
|
+
import { createGrpcWebTransport } from "@connectrpc/connect-web";
|
|
387
|
+
import { ItemService } from "./gen/demo/v1/items_pb";
|
|
388
|
+
|
|
389
|
+
const client = createClient(ItemService, createGrpcWebTransport({ baseUrl: "https://api.example.com" }));
|
|
390
|
+
const item = await client.getItem({ id: 1n });
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
- Unary and server-streaming RPCs work; browsers can't do client or
|
|
394
|
+
bidirectional streaming.
|
|
395
|
+
- Errors, response headers and trailers reach the browser as usual.
|
|
396
|
+
- Native gRPC clients keep working on the same port. Enabling gRPC-Web
|
|
397
|
+
also accepts HTTP/1.1, which browsers use without TLS; over HTTPS they
|
|
398
|
+
negotiate HTTP/2.
|
|
399
|
+
- `grpc_web_origins` sets CORS: listed origins may call with credentials
|
|
400
|
+
(cookies); `"*"` allows any origin without credentials (logged as a
|
|
401
|
+
warning). `grpc_web_allowed_headers` adds request headers beyond the
|
|
402
|
+
standard ones (`authorization` is already allowed).
|
|
403
|
+
|
|
302
404
|
## Testing handlers
|
|
303
405
|
|
|
304
406
|
`Tonic::Rails::Testing` calls a handler in-process, with real protobuf
|
|
@@ -322,6 +424,30 @@ result.headers["x-cache"] # => "hit"
|
|
|
322
424
|
result.trailers["x-cost-ms"] # => "3"
|
|
323
425
|
```
|
|
324
426
|
|
|
427
|
+
Matchers for gRPC statuses work with `Testing.call` and with the official
|
|
428
|
+
`grpc` client's errors:
|
|
429
|
+
|
|
430
|
+
```ruby
|
|
431
|
+
# spec/rails_helper.rb
|
|
432
|
+
require "tonic/rails/testing/rspec"
|
|
433
|
+
|
|
434
|
+
expect { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
|
|
435
|
+
.to raise_grpc_error(:not_found).with_message(/0/)
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
```ruby
|
|
439
|
+
# Minitest
|
|
440
|
+
require "tonic/rails/testing/minitest"
|
|
441
|
+
|
|
442
|
+
class ItemServiceTest < ActiveSupport::TestCase
|
|
443
|
+
include Tonic::Rails::Testing::Assertions
|
|
444
|
+
|
|
445
|
+
test "missing items" do
|
|
446
|
+
assert_grpc_error(:not_found) { Tonic::Rails::Testing.call(ItemService, :GetItem, { id: 0 }) }
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
```
|
|
450
|
+
|
|
325
451
|
## Configuration
|
|
326
452
|
|
|
327
453
|
Set options in `config/application.rb` or an environment file:
|
|
@@ -353,6 +479,11 @@ end
|
|
|
353
479
|
| `tls_client_ca_path` | `nil` | PEM CA bundle; requires client certificates (mutual TLS) |
|
|
354
480
|
| `drain_timeout` | `10` | Seconds to let in-flight calls finish on shutdown |
|
|
355
481
|
| `keepalive_interval`, `keepalive_timeout` | `15`, `5` | HTTP/2 PING interval and timeout, seconds |
|
|
482
|
+
| `grpc_web` | `false` | Serve gRPC-Web for browser clients (see [Browser clients](#browser-clients-grpc-web)) |
|
|
483
|
+
| `grpc_web_origins` | `[]` | Browser origins allowed via CORS; `"*"` for any (no credentials) |
|
|
484
|
+
| `grpc_web_allowed_headers`, `grpc_web_exposed_headers` | `[]` | Extra CORS request / response headers |
|
|
485
|
+
| `health_check` | `nil` | Callable; while false or raising, health reports `NOT_SERVING` |
|
|
486
|
+
| `health_check_interval` | `5` | Seconds between health checks |
|
|
356
487
|
| `compression` | `false` | gzip responses over 1 KiB for clients that accept it |
|
|
357
488
|
| `reflection` | `false` | Serve `grpc.reflection.v1`; exposes your schema |
|
|
358
489
|
| `interceptors` | `[]` | See [Interceptors](#interceptors) |
|
|
@@ -399,7 +530,19 @@ Don't combine it with the plugin.
|
|
|
399
530
|
- **Health checks.** `grpc.health.v1.Health` reports `SERVING` for the
|
|
400
531
|
server (`""`) and every registered service, and flips to `NOT_SERVING`
|
|
401
532
|
as soon as shutdown begins. Point your load balancer or Kubernetes
|
|
402
|
-
gRPC probes at it.
|
|
533
|
+
gRPC probes at it. To make readiness reflect your dependencies, add a
|
|
534
|
+
check that runs every `health_check_interval` seconds (default 5):
|
|
535
|
+
|
|
536
|
+
```ruby
|
|
537
|
+
config.health_check = -> { ActiveRecord::Base.connection.active? }
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
While it returns false or raises, the server reports `NOT_SERVING`
|
|
541
|
+
(calls are still served). You can also mark the server or a service
|
|
542
|
+
yourself, for example during maintenance:
|
|
543
|
+
`Tonic::Rails.server.not_serving!("demo.v1.ItemService")` and
|
|
544
|
+
`serving!`. A service is `SERVING` only while the check passes and it
|
|
545
|
+
isn't marked otherwise.
|
|
403
546
|
- **Graceful shutdown.** On SIGTERM or SIGINT the server stops
|
|
404
547
|
accepting connections, tells clients to go away (HTTP/2 GOAWAY), lets
|
|
405
548
|
in-flight calls finish for up to `drain_timeout` seconds, then exits.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "tonic_rails_native"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
rust-version = "1.88"
|
|
6
6
|
publish = false
|
|
@@ -28,6 +28,8 @@ rb-sys = "0.9"
|
|
|
28
28
|
tonic = { version = "0.14", default-features = false, features = ["transport", "router", "server", "tls-ring"] }
|
|
29
29
|
tonic-reflection = { version = "0.14", default-features = false, features = ["server"] }
|
|
30
30
|
tonic-health = { version = "0.14", default-features = false }
|
|
31
|
+
tonic-web = { version = "0.14", default-features = false }
|
|
32
|
+
tower-http = { version = "0.6", default-features = false, features = ["cors"] }
|
|
31
33
|
axum = { version = "0.8", default-features = false }
|
|
32
34
|
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros", "time"] }
|
|
33
35
|
tokio-stream = { version = "0.1", features = ["net"] }
|
|
@@ -21,10 +21,11 @@ use magnus::{Value, value::Opaque};
|
|
|
21
21
|
use tokio::sync::mpsc;
|
|
22
22
|
use tonic::Code;
|
|
23
23
|
use tonic::body::Body as TonicBody;
|
|
24
|
+
use tonic::transport::server::{TcpConnectInfo, TlsConnectInfo};
|
|
24
25
|
|
|
25
26
|
use crate::body::{ErrorBody, ResponseBody, ServerStats};
|
|
26
27
|
use crate::framing::{MessageReader, append_metadata, parse_grpc_timeout};
|
|
27
|
-
use crate::gvl::{DispatchContext, RequestError, StreamMessage};
|
|
28
|
+
use crate::gvl::{DispatchContext, Peer, RequestError, StreamMessage};
|
|
28
29
|
use crate::log::Level;
|
|
29
30
|
|
|
30
31
|
/// Messages buffered per direction before backpressure applies.
|
|
@@ -166,6 +167,7 @@ impl DispatchInner {
|
|
|
166
167
|
request_rx,
|
|
167
168
|
request_error: request_error.clone(),
|
|
168
169
|
log: self.stats.log.clone(),
|
|
170
|
+
peer: peer_of(&req),
|
|
169
171
|
});
|
|
170
172
|
|
|
171
173
|
match self.request_tx.try_send(ctx) {
|
|
@@ -241,6 +243,26 @@ impl DispatchInner {
|
|
|
241
243
|
}
|
|
242
244
|
}
|
|
243
245
|
|
|
246
|
+
/// The client's address and, if it presented one over TLS, its
|
|
247
|
+
/// certificate chain. tonic records both on the request.
|
|
248
|
+
fn peer_of<B>(req: &Request<B>) -> Peer {
|
|
249
|
+
let address = req
|
|
250
|
+
.extensions()
|
|
251
|
+
.get::<TcpConnectInfo>()
|
|
252
|
+
.and_then(TcpConnectInfo::remote_addr)
|
|
253
|
+
.map(|addr| addr.to_string());
|
|
254
|
+
let certificates = req
|
|
255
|
+
.extensions()
|
|
256
|
+
.get::<TlsConnectInfo<TcpConnectInfo>>()
|
|
257
|
+
.and_then(TlsConnectInfo::peer_certs)
|
|
258
|
+
.map(|chain| chain.iter().map(|der| der.to_vec()).collect())
|
|
259
|
+
.unwrap_or_default();
|
|
260
|
+
Peer {
|
|
261
|
+
address,
|
|
262
|
+
certificates,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
244
266
|
/// Read the request body and forward each unframed message to the
|
|
245
267
|
/// handler. Dropping `tx` signals end of stream; errors are recorded
|
|
246
268
|
/// in `error` first so the reader can distinguish them.
|
|
@@ -369,6 +369,15 @@ pub struct DispatchContext {
|
|
|
369
369
|
pub request_rx: mpsc::Receiver<Vec<u8>>,
|
|
370
370
|
pub request_error: Arc<RequestError>,
|
|
371
371
|
pub log: LogSink,
|
|
372
|
+
pub peer: Peer,
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/// The client connection: remote address and, with mutual TLS, its
|
|
376
|
+
/// certificate chain (DER, leaf first).
|
|
377
|
+
#[derive(Default)]
|
|
378
|
+
pub struct Peer {
|
|
379
|
+
pub address: Option<String>,
|
|
380
|
+
pub certificates: Vec<Vec<u8>>,
|
|
372
381
|
}
|
|
373
382
|
|
|
374
383
|
/// Bounds on how long one thread keeps the GVL to drain a busy queue.
|
|
@@ -441,6 +450,7 @@ fn run_handler(ctx: DispatchContext) {
|
|
|
441
450
|
request_rx,
|
|
442
451
|
request_error,
|
|
443
452
|
log,
|
|
453
|
+
peer,
|
|
444
454
|
} = ctx;
|
|
445
455
|
|
|
446
456
|
let trailers = Arc::new(Mutex::new(Metadata::new()));
|
|
@@ -454,6 +464,7 @@ fn run_handler(ctx: DispatchContext) {
|
|
|
454
464
|
request_rx,
|
|
455
465
|
request_error,
|
|
456
466
|
trailers.clone(),
|
|
467
|
+
&peer,
|
|
457
468
|
);
|
|
458
469
|
let end = StreamMessage::End {
|
|
459
470
|
status: result.err().map(|e| error_status(&e, &log)),
|
|
@@ -474,6 +485,7 @@ fn call_handler(
|
|
|
474
485
|
request_rx: mpsc::Receiver<Vec<u8>>,
|
|
475
486
|
request_error: Arc<RequestError>,
|
|
476
487
|
trailers: Arc<Mutex<Metadata>>,
|
|
488
|
+
peer: &Peer,
|
|
477
489
|
) -> Result<(), Error> {
|
|
478
490
|
let md = ruby.hash_new_capa(metadata.len());
|
|
479
491
|
for (k, v) in metadata {
|
|
@@ -496,6 +508,16 @@ fn call_handler(
|
|
|
496
508
|
if let Some(ms) = timeout_ms {
|
|
497
509
|
call.aset(ruby.to_symbol("timeout_ms"), ms as u64)?;
|
|
498
510
|
}
|
|
511
|
+
if let Some(address) = &peer.address {
|
|
512
|
+
call.aset(ruby.to_symbol("peer_address"), ruby.str_new(address))?;
|
|
513
|
+
}
|
|
514
|
+
if !peer.certificates.is_empty() {
|
|
515
|
+
let certificates = ruby.ary_new_capa(peer.certificates.len());
|
|
516
|
+
for der in &peer.certificates {
|
|
517
|
+
certificates.push(ruby.str_from_slice(der))?;
|
|
518
|
+
}
|
|
519
|
+
call.aset(ruby.to_symbol("peer_certificates"), certificates)?;
|
|
520
|
+
}
|
|
499
521
|
|
|
500
522
|
let callback = ruby.get_inner(callback);
|
|
501
523
|
let result = callback.funcall::<_, _, Value>("call", (ruby.str_new(path), md, call));
|
|
@@ -64,6 +64,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
64
64
|
)?;
|
|
65
65
|
class.define_method("set_compression", method!(NativeServer::set_compression, 1))?;
|
|
66
66
|
class.define_method("set_reflection", method!(NativeServer::set_reflection, 1))?;
|
|
67
|
+
class.define_method("set_grpc_web", method!(NativeServer::set_grpc_web, 3))?;
|
|
67
68
|
class.define_method("set_routes", method!(NativeServer::set_routes, 1))?;
|
|
68
69
|
class.define_method(
|
|
69
70
|
"set_reflection_descriptors",
|
|
@@ -71,6 +72,7 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
|
71
72
|
)?;
|
|
72
73
|
class.define_method("start_server", method!(NativeServer::start_server, 0))?;
|
|
73
74
|
class.define_method("serve_worker", method!(NativeServer::serve_worker, 0))?;
|
|
75
|
+
class.define_method("set_health", method!(NativeServer::set_health, 2))?;
|
|
74
76
|
class.define_method("begin_shutdown", method!(NativeServer::begin_shutdown, 0))?;
|
|
75
77
|
class.define_method("wait_for_drain", method!(NativeServer::wait_for_drain, 1))?;
|
|
76
78
|
class.define_method("stop_workers", method!(NativeServer::stop_workers, 0))?;
|
|
@@ -29,6 +29,9 @@ use tokio::sync::{Semaphore, watch};
|
|
|
29
29
|
use tonic_health::ServingStatus;
|
|
30
30
|
use tonic_health::server::HealthReporter;
|
|
31
31
|
|
|
32
|
+
use http::{HeaderName, HeaderValue, Method};
|
|
33
|
+
use tower_http::cors::{AllowOrigin, CorsLayer};
|
|
34
|
+
|
|
32
35
|
use crate::body::ServerStats;
|
|
33
36
|
use crate::dispatch::{DispatchInner, DispatchService};
|
|
34
37
|
use crate::gvl::{self, Cancel, DispatchContext};
|
|
@@ -47,6 +50,7 @@ struct Options {
|
|
|
47
50
|
keepalive_interval: Duration,
|
|
48
51
|
keepalive_timeout: Duration,
|
|
49
52
|
reflection: bool,
|
|
53
|
+
grpc_web: Option<GrpcWebOptions>,
|
|
50
54
|
dispatch_queue_size: usize,
|
|
51
55
|
routes: Vec<String>,
|
|
52
56
|
descriptors: Vec<Vec<u8>>,
|
|
@@ -63,6 +67,7 @@ impl Default for Options {
|
|
|
63
67
|
keepalive_interval: Duration::from_secs(15),
|
|
64
68
|
keepalive_timeout: Duration::from_secs(5),
|
|
65
69
|
reflection: false,
|
|
70
|
+
grpc_web: None,
|
|
66
71
|
dispatch_queue_size: 128,
|
|
67
72
|
routes: Vec::new(),
|
|
68
73
|
descriptors: Vec::new(),
|
|
@@ -70,6 +75,76 @@ impl Default for Options {
|
|
|
70
75
|
}
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
/// gRPC-Web and the CORS policy browsers need to use it.
|
|
79
|
+
struct GrpcWebOptions {
|
|
80
|
+
/// Allowed browser origins; empty = same-origin only; ["*"] = any.
|
|
81
|
+
origins: Vec<String>,
|
|
82
|
+
allow_headers: Vec<String>,
|
|
83
|
+
expose_headers: Vec<String>,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Request headers gRPC-Web clients send, beyond the CORS-safelisted ones.
|
|
87
|
+
const GRPC_WEB_REQUEST_HEADERS: [&str; 5] = [
|
|
88
|
+
"content-type",
|
|
89
|
+
"x-grpc-web",
|
|
90
|
+
"x-user-agent",
|
|
91
|
+
"grpc-timeout",
|
|
92
|
+
"authorization",
|
|
93
|
+
];
|
|
94
|
+
/// Response headers browsers must be allowed to read for gRPC-Web.
|
|
95
|
+
const GRPC_WEB_RESPONSE_HEADERS: [&str; 3] = ["grpc-status", "grpc-message", "grpc-status-details-bin"];
|
|
96
|
+
|
|
97
|
+
fn header_names(ruby: &Ruby, defaults: &[&str], extra: &[String]) -> Result<Vec<HeaderName>, Error> {
|
|
98
|
+
defaults
|
|
99
|
+
.iter()
|
|
100
|
+
.map(|name| name.to_string())
|
|
101
|
+
.chain(extra.iter().map(|name| name.to_ascii_lowercase()))
|
|
102
|
+
.map(|name| {
|
|
103
|
+
HeaderName::from_bytes(name.as_bytes())
|
|
104
|
+
.map_err(|_| arg_error(ruby, format!("invalid gRPC-Web header name: {name:?}")))
|
|
105
|
+
})
|
|
106
|
+
.collect()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/// None when no cross-origin access is configured (same-origin only).
|
|
110
|
+
fn cors_layer(ruby: &Ruby, web: &GrpcWebOptions) -> Result<Option<CorsLayer>, Error> {
|
|
111
|
+
if web.origins.is_empty() {
|
|
112
|
+
return Ok(None);
|
|
113
|
+
}
|
|
114
|
+
let layer = CorsLayer::new()
|
|
115
|
+
.allow_methods([Method::POST])
|
|
116
|
+
.allow_headers(header_names(ruby, &GRPC_WEB_REQUEST_HEADERS, &web.allow_headers)?)
|
|
117
|
+
.expose_headers(header_names(
|
|
118
|
+
ruby,
|
|
119
|
+
&GRPC_WEB_RESPONSE_HEADERS,
|
|
120
|
+
&web.expose_headers,
|
|
121
|
+
)?)
|
|
122
|
+
.max_age(Duration::from_secs(24 * 60 * 60));
|
|
123
|
+
if web.origins.iter().any(|origin| origin == "*") {
|
|
124
|
+
if web.origins.len() > 1 {
|
|
125
|
+
return Err(arg_error(
|
|
126
|
+
ruby,
|
|
127
|
+
"grpc_web_origins: \"*\" can't be combined with other origins",
|
|
128
|
+
));
|
|
129
|
+
}
|
|
130
|
+
// Browsers refuse credentials with a wildcard origin, so none here.
|
|
131
|
+
return Ok(Some(layer.allow_origin(AllowOrigin::any())));
|
|
132
|
+
}
|
|
133
|
+
let origins = web
|
|
134
|
+
.origins
|
|
135
|
+
.iter()
|
|
136
|
+
.map(|origin| {
|
|
137
|
+
HeaderValue::from_str(origin.trim_end_matches('/'))
|
|
138
|
+
.map_err(|_| arg_error(ruby, format!("invalid gRPC-Web origin: {origin:?}")))
|
|
139
|
+
})
|
|
140
|
+
.collect::<Result<Vec<_>, _>>()?;
|
|
141
|
+
Ok(Some(
|
|
142
|
+
layer
|
|
143
|
+
.allow_origin(AllowOrigin::list(origins))
|
|
144
|
+
.allow_credentials(true),
|
|
145
|
+
))
|
|
146
|
+
}
|
|
147
|
+
|
|
73
148
|
/// State that exists only while the server is running.
|
|
74
149
|
struct Running {
|
|
75
150
|
runtime: Runtime,
|
|
@@ -199,6 +274,20 @@ impl NativeServer {
|
|
|
199
274
|
self.compression.store(enabled, Ordering::Relaxed);
|
|
200
275
|
}
|
|
201
276
|
|
|
277
|
+
/// Enable gRPC-Web (and HTTP/1.1, which browsers use without TLS).
|
|
278
|
+
pub fn set_grpc_web(
|
|
279
|
+
&self,
|
|
280
|
+
origins: Vec<String>,
|
|
281
|
+
allow_headers: Vec<String>,
|
|
282
|
+
expose_headers: Vec<String>,
|
|
283
|
+
) {
|
|
284
|
+
self.options.lock().grpc_web = Some(GrpcWebOptions {
|
|
285
|
+
origins,
|
|
286
|
+
allow_headers,
|
|
287
|
+
expose_headers,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
202
291
|
pub fn set_reflection(&self, enabled: bool) {
|
|
203
292
|
self.options.lock().reflection = enabled;
|
|
204
293
|
}
|
|
@@ -347,7 +436,17 @@ impl NativeServer {
|
|
|
347
436
|
.tls_config(tls)
|
|
348
437
|
.map_err(|e| runtime_error(ruby, format!("invalid TLS configuration: {e}")))?;
|
|
349
438
|
}
|
|
350
|
-
|
|
439
|
+
// CORS outermost, so preflight requests are answered before the
|
|
440
|
+
// gRPC-Web translation sees them. Both are no-ops when disabled.
|
|
441
|
+
let (cors, grpc_web) = match &options.grpc_web {
|
|
442
|
+
Some(web) => (cors_layer(ruby, web)?, Some(tonic_web::GrpcWebLayer::new())),
|
|
443
|
+
None => (None, None),
|
|
444
|
+
};
|
|
445
|
+
let router = builder
|
|
446
|
+
.accept_http1(options.grpc_web.is_some())
|
|
447
|
+
.layer(tower::util::option_layer(cors))
|
|
448
|
+
.layer(tower::util::option_layer(grpc_web))
|
|
449
|
+
.add_routes(grpc_routes);
|
|
351
450
|
|
|
352
451
|
let permits = match options.max_connections {
|
|
353
452
|
0 => Semaphore::MAX_PERMITS,
|
|
@@ -409,6 +508,27 @@ impl NativeServer {
|
|
|
409
508
|
Ok(())
|
|
410
509
|
}
|
|
411
510
|
|
|
511
|
+
/// Set the grpc.health.v1 status of one service ("" = the whole
|
|
512
|
+
/// server). Returns false if the server isn't running.
|
|
513
|
+
pub fn set_health(&self, service: String, serving: bool) -> bool {
|
|
514
|
+
let Some(r) = self
|
|
515
|
+
.running
|
|
516
|
+
.lock()
|
|
517
|
+
.as_ref()
|
|
518
|
+
.map(|r| (r.runtime.handle().clone(), r.health.clone()))
|
|
519
|
+
else {
|
|
520
|
+
return false;
|
|
521
|
+
};
|
|
522
|
+
let (handle, health) = r;
|
|
523
|
+
let status = if serving {
|
|
524
|
+
ServingStatus::Serving
|
|
525
|
+
} else {
|
|
526
|
+
ServingStatus::NotServing
|
|
527
|
+
};
|
|
528
|
+
handle.block_on(health.set_service_status(service, status));
|
|
529
|
+
true
|
|
530
|
+
}
|
|
531
|
+
|
|
412
532
|
/// Mark everything NOT_SERVING and stop accepting new connections.
|
|
413
533
|
/// Existing connections receive GOAWAY; in-flight RPCs continue.
|
|
414
534
|
pub fn begin_shutdown(&self) {
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module TonicRails
|
|
6
|
+
module Generators
|
|
7
|
+
# rails generate tonic_rails:install
|
|
8
|
+
#
|
|
9
|
+
# Sets an app up for gRPC: an initializer (load path for generated
|
|
10
|
+
# protobuf code, configuration, handler registration), the proto and
|
|
11
|
+
# generated-code directories, and the Puma plugin.
|
|
12
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
13
|
+
source_root File.expand_path("templates", __dir__)
|
|
14
|
+
|
|
15
|
+
class_option :skip_puma, type: :boolean, default: false,
|
|
16
|
+
desc: "Don't add `plugin :tonic_rails` to config/puma.rb"
|
|
17
|
+
class_option :skip_next_steps, type: :boolean, default: false, hide: true
|
|
18
|
+
|
|
19
|
+
def create_initializer
|
|
20
|
+
template "tonic_rails.rb.tt", "config/initializers/tonic_rails.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_directories
|
|
24
|
+
create_file "app/protos/.keep"
|
|
25
|
+
create_file "lib/grpc/.keep"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def add_puma_plugin
|
|
29
|
+
return if options[:skip_puma]
|
|
30
|
+
|
|
31
|
+
puma = "config/puma.rb"
|
|
32
|
+
return unless File.exist?(File.join(destination_root, puma))
|
|
33
|
+
return if File.read(File.join(destination_root, puma)).match?(/plugin\s+:tonic_rails/)
|
|
34
|
+
|
|
35
|
+
append_to_file puma, "\n# Run the tonic-rails gRPC server alongside Puma.\nplugin :tonic_rails\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def show_next_steps
|
|
39
|
+
return if options[:skip_next_steps]
|
|
40
|
+
|
|
41
|
+
say "\nNext: rails generate tonic_rails:service Item GetItem ListItems:server", :green
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|