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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f526af49b4f7647168bcdf7d64131c00c63880d3372f0355d60aa7e347240856
4
- data.tar.gz: 7698d7d70a8e3c75ef3ee63ec46ce032267688e12cb86fbca35bf74e41f6f29f
3
+ metadata.gz: df7cc56c6ca7ca2594bc38ab5e676d9c68f6dbcd6464155d14b3d7a83ef98ef2
4
+ data.tar.gz: 9763e38411184f3c9a796f9e72bf9238ae3e20c16c7687cefa3792192cba7516
5
5
  SHA512:
6
- metadata.gz: ad814eb2ab41e6009649bd5307a8f304e3f1baab030f00a25045b65ec0bd1279aa7eceac041d141932281073ce650172191e0fcfbec9c74066f5c224b64e811c
7
- data.tar.gz: b1cc149c6beb0f3bb2f99579cc5b4ef4e13964654a93969b431bf0032a25f9fa624f5888748eb0fe37fef1c13762146b881aec6d3347cbc27fe0fee468990275
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.2.0...HEAD
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.2.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.2.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
- let router = builder.add_routes(grpc_routes);
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