tonic-rails 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -2
  3. data/Cargo.lock +5 -5
  4. data/README.md +204 -13
  5. data/ext/tonic_rails_native/Cargo.toml +2 -2
  6. data/ext/tonic_rails_native/src/body.rs +113 -49
  7. data/ext/tonic_rails_native/src/dispatch.rs +67 -8
  8. data/ext/tonic_rails_native/src/framing.rs +50 -0
  9. data/ext/tonic_rails_native/src/gvl.rs +109 -21
  10. data/ext/tonic_rails_native/src/lib.rs +6 -1
  11. data/ext/tonic_rails_native/src/log.rs +131 -0
  12. data/ext/tonic_rails_native/src/net.rs +7 -1
  13. data/ext/tonic_rails_native/src/server.rs +67 -6
  14. data/lib/generators/tonic_rails/install/install_generator.rb +45 -0
  15. data/lib/generators/tonic_rails/install/templates/tonic_rails.rb.tt +21 -0
  16. data/lib/generators/tonic_rails/service/service_generator.rb +110 -0
  17. data/lib/generators/tonic_rails/service/templates/handler.rb.tt +38 -0
  18. data/lib/generators/tonic_rails/service/templates/service.proto.tt +16 -0
  19. data/lib/generators/tonic_rails/service/templates/service_spec.rb.tt +17 -0
  20. data/lib/generators/tonic_rails/service/templates/service_test.rb.tt +14 -0
  21. data/lib/puma/plugin/tonic_rails.rb +116 -0
  22. data/lib/tonic/rails/base.rb +169 -23
  23. data/lib/tonic/rails/configuration.rb +27 -4
  24. data/lib/tonic/rails/health.rb +105 -0
  25. data/lib/tonic/rails/instrumentation.rb +78 -0
  26. data/lib/tonic/rails/metadata.rb +50 -0
  27. data/lib/tonic/rails/peer.rb +60 -0
  28. data/lib/tonic/rails/railtie.rb +2 -2
  29. data/lib/tonic/rails/router.rb +1 -0
  30. data/lib/tonic/rails/rpc_error.rb +73 -6
  31. data/lib/tonic/rails/server.rb +82 -12
  32. data/lib/tonic/rails/tasks/tonic-rails.rake +13 -1
  33. data/lib/tonic/rails/testing/errors.rb +47 -0
  34. data/lib/tonic/rails/testing/minitest.rb +44 -0
  35. data/lib/tonic/rails/testing/rspec.rb +53 -0
  36. data/lib/tonic/rails/testing.rb +35 -4
  37. data/lib/tonic/rails/version.rb +1 -1
  38. data/lib/tonic-rails.rb +4 -0
  39. metadata +17 -1
@@ -7,7 +7,7 @@
7
7
  use std::future::Future;
8
8
  use std::pin::Pin;
9
9
  use std::sync::Arc;
10
- use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10
+ use std::sync::atomic::{AtomicUsize, Ordering};
11
11
  use std::task::{Context, Poll};
12
12
  use std::time::Instant;
13
13
 
@@ -17,8 +17,9 @@ use http_body::{Body, Frame};
17
17
  use tokio::sync::mpsc;
18
18
  use tonic::{Code, Status};
19
19
 
20
- use crate::framing::{COMPRESSION_THRESHOLD, frame, sanitize_for_log, status_trailers};
20
+ use crate::framing::{COMPRESSION_THRESHOLD, append_metadata, frame, status_trailers};
21
21
  use crate::gvl::StreamMessage;
22
+ use crate::log::{Level, LogSink, status_name};
22
23
 
23
24
  /// Request counters, exposed to Ruby via `Server#stats`.
24
25
  #[derive(Default)]
@@ -30,12 +31,14 @@ pub struct ServerStats {
30
31
  pub deadline_exceeded: AtomicUsize,
31
32
  pub resource_exhausted: AtomicUsize,
32
33
  pub unimplemented: AtomicUsize,
33
- /// Log every request (including OK) to stderr. Errors are always logged.
34
- pub log_requests: AtomicBool,
34
+ pub log: LogSink,
35
35
  }
36
36
 
37
37
  impl ServerStats {
38
- pub fn record(&self, path: &str, code: u32, message: &str, start: Instant) {
38
+ /// Count a finished call. `transport` marks outcomes decided here in
39
+ /// Rust (rejections, deadlines, limits) rather than by the Ruby handler;
40
+ /// only those are logged, since Ruby instruments and logs its own calls.
41
+ pub fn record(&self, path: &str, code: u32, message: &str, start: Instant, transport: bool) {
39
42
  let counter = match Code::from_i32(code as i32) {
40
43
  Code::Ok => &self.successful,
41
44
  Code::Cancelled => &self.cancelled,
@@ -55,16 +58,15 @@ impl ServerStats {
55
58
  _ => {}
56
59
  }
57
60
 
58
- let is_error = !matches!(Code::from_i32(code as i32), Code::Ok | Code::Cancelled);
59
- if is_error || self.log_requests.load(Ordering::Relaxed) {
60
- let path = sanitize_for_log(path);
61
- let dur_us = start.elapsed().as_micros();
62
- if message.is_empty() {
63
- eprintln!("tonic-rails: path={path} code={code} duration_us={dur_us}");
61
+ if transport && code != Code::Ok as u32 {
62
+ let ms = start.elapsed().as_secs_f64() * 1000.0;
63
+ let status = status_name(code);
64
+ let line = if message.is_empty() {
65
+ format!("gRPC {path} {status} in {ms:.1}ms")
64
66
  } else {
65
- let message = sanitize_for_log(message);
66
- eprintln!("tonic-rails: path={path} code={code} duration_us={dur_us} message={message}");
67
- }
67
+ format!("gRPC {path} {status} in {ms:.1}ms: {message}")
68
+ };
69
+ self.log.log(Level::for_code(code), &line);
68
70
  }
69
71
  }
70
72
  }
@@ -102,6 +104,8 @@ impl Body for ErrorBody {
102
104
  /// channel. Each `Data` message becomes a gRPC frame; `End` (or the
103
105
  /// deadline) produces the trailers.
104
106
  pub struct ResponseBody {
107
+ /// A message already received while waiting for response headers.
108
+ pub pending: Option<StreamMessage>,
105
109
  pub receiver: Option<mpsc::Receiver<StreamMessage>>,
106
110
  pub stats: Arc<ServerStats>,
107
111
  pub path: String,
@@ -112,12 +116,63 @@ pub struct ResponseBody {
112
116
  pub finished: bool,
113
117
  }
114
118
 
119
+ type Polled = Poll<Option<Result<Frame<Bytes>, Status>>>;
120
+
115
121
  impl ResponseBody {
116
- fn finish(&mut self, code: Code, message: &str) -> Poll<Option<Result<Frame<Bytes>, Status>>> {
122
+ fn finish(&mut self, code: Code, message: &str, transport: bool) -> Polled {
123
+ self.finish_with(code, message, transport, &[])
124
+ }
125
+
126
+ fn finish_with(
127
+ &mut self,
128
+ code: Code,
129
+ message: &str,
130
+ transport: bool,
131
+ metadata: &[(String, String)],
132
+ ) -> Polled {
117
133
  self.receiver = None;
118
134
  self.finished = true;
119
- self.stats.record(&self.path, code as u32, message, self.start);
120
- Poll::Ready(Some(Ok(Frame::trailers(status_trailers(code as u32, message)))))
135
+ self.stats
136
+ .record(&self.path, code as u32, message, self.start, transport);
137
+ let mut trailers = status_trailers(code as u32, message);
138
+ let skipped = append_metadata(&mut trailers, metadata);
139
+ if !skipped.is_empty() {
140
+ self.stats.log.log(
141
+ Level::Warn,
142
+ &format!(
143
+ "gRPC {} dropped invalid or reserved trailers: {}",
144
+ self.path,
145
+ skipped.join(", ")
146
+ ),
147
+ );
148
+ }
149
+ Poll::Ready(Some(Ok(Frame::trailers(trailers))))
150
+ }
151
+
152
+ /// Turn one handler message into a frame (None: nothing to emit).
153
+ fn handle(&mut self, message: StreamMessage) -> Option<Polled> {
154
+ match message {
155
+ StreamMessage::Data(bytes) => {
156
+ if bytes.len() > self.max_message_size {
157
+ return Some(self.finish(
158
+ Code::ResourceExhausted,
159
+ "response message exceeds max_message_size",
160
+ true,
161
+ ));
162
+ }
163
+ let compress = self.compress && bytes.len() > COMPRESSION_THRESHOLD;
164
+ Some(Poll::Ready(Some(Ok(Frame::data(frame(&bytes, compress))))))
165
+ }
166
+ StreamMessage::End { status, trailers } => Some(match status {
167
+ None => self.finish_with(Code::Ok, "", false, &trailers),
168
+ Some((code, message)) => {
169
+ self.finish_with(Code::from_i32(code as i32), &message, false, &trailers)
170
+ }
171
+ }),
172
+ // Headers are only valid first and are consumed by the
173
+ // dispatcher; a late one has nowhere to go.
174
+ StreamMessage::Headers(_) => None,
175
+ }
121
176
  }
122
177
  }
123
178
 
@@ -125,39 +180,36 @@ impl Body for ResponseBody {
125
180
  type Data = Bytes;
126
181
  type Error = Status;
127
182
 
128
- fn poll_frame(
129
- mut self: Pin<&mut Self>,
130
- cx: &mut Context<'_>,
131
- ) -> Poll<Option<Result<Frame<Bytes>, Status>>> {
132
- let Some(receiver) = self.receiver.as_mut() else {
133
- return Poll::Ready(None);
134
- };
183
+ fn poll_frame(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Polled {
184
+ if let Some(message) = self.pending.take()
185
+ && let Some(poll) = self.handle(message)
186
+ {
187
+ return poll;
188
+ }
135
189
 
136
190
  // Drain ready messages before checking the deadline so a response
137
191
  // that made it in time is never discarded.
138
- match receiver.poll_recv(cx) {
139
- Poll::Ready(Some(StreamMessage::Data(bytes))) => {
140
- if bytes.len() > self.max_message_size {
141
- return self.finish(
142
- Code::ResourceExhausted,
143
- "response message exceeds max_message_size",
144
- );
192
+ loop {
193
+ let Some(receiver) = self.receiver.as_mut() else {
194
+ return Poll::Ready(None);
195
+ };
196
+ match receiver.poll_recv(cx) {
197
+ Poll::Ready(Some(message)) => {
198
+ if let Some(poll) = self.handle(message) {
199
+ return poll;
200
+ }
145
201
  }
146
- let compress = self.compress && bytes.len() > COMPRESSION_THRESHOLD;
147
- return Poll::Ready(Some(Ok(Frame::data(frame(&bytes, compress)))));
148
- }
149
- Poll::Ready(Some(StreamMessage::End(None))) => return self.finish(Code::Ok, ""),
150
- Poll::Ready(Some(StreamMessage::End(Some((code, message))))) => {
151
- return self.finish(Code::from_i32(code as i32), &message);
202
+ Poll::Ready(None) => {
203
+ return self.finish(Code::Internal, "handler ended without a response", true);
204
+ }
205
+ Poll::Pending => break,
152
206
  }
153
- Poll::Ready(None) => return self.finish(Code::Internal, "handler ended without a response"),
154
- Poll::Pending => {}
155
207
  }
156
208
 
157
209
  if let Some(deadline) = self.deadline.as_mut()
158
210
  && deadline.as_mut().poll(cx).is_ready()
159
211
  {
160
- return self.finish(Code::DeadlineExceeded, "deadline exceeded");
212
+ return self.finish(Code::DeadlineExceeded, "deadline exceeded", true);
161
213
  }
162
214
  Poll::Pending
163
215
  }
@@ -173,12 +225,6 @@ impl Drop for ResponseBody {
173
225
  // (RST_STREAM / disconnect) — count it as cancelled.
174
226
  if !self.finished {
175
227
  self.stats.cancelled.fetch_add(1, Ordering::Relaxed);
176
- if self.stats.log_requests.load(Ordering::Relaxed) {
177
- eprintln!(
178
- "tonic-rails: path={} code=1 message=cancelled by client",
179
- sanitize_for_log(&self.path)
180
- );
181
- }
182
228
  }
183
229
  }
184
230
  }
@@ -197,6 +243,7 @@ mod tests {
197
243
 
198
244
  fn response_body(rx: mpsc::Receiver<StreamMessage>, stats: Arc<ServerStats>) -> ResponseBody {
199
245
  ResponseBody {
246
+ pending: None,
200
247
  receiver: Some(rx),
201
248
  stats,
202
249
  path: "/test.Svc/Method".into(),
@@ -231,7 +278,11 @@ mod tests {
231
278
  let stats = Arc::new(ServerStats::default());
232
279
  let mut body = response_body(rx, stats.clone());
233
280
  tx.try_send(StreamMessage::Data(b"abc".to_vec())).ok();
234
- tx.try_send(StreamMessage::End(None)).ok();
281
+ tx.try_send(StreamMessage::End {
282
+ status: None,
283
+ trailers: vec![("x-done".into(), "yes".into())],
284
+ })
285
+ .ok();
235
286
 
236
287
  match poll(&mut body) {
237
288
  Poll::Ready(Some(Ok(frame))) => {
@@ -242,7 +293,9 @@ mod tests {
242
293
  }
243
294
  _ => panic!("expected data"),
244
295
  }
245
- assert_eq!(trailers(poll(&mut body))["grpc-status"], "0");
296
+ let t = trailers(poll(&mut body));
297
+ assert_eq!(t["grpc-status"], "0");
298
+ assert_eq!(t["x-done"], "yes");
246
299
  assert!(matches!(poll(&mut body), Poll::Ready(None)));
247
300
  drop(body);
248
301
  assert_eq!(stats.successful.load(Ordering::Relaxed), 1);
@@ -263,12 +316,23 @@ mod tests {
263
316
  fn response_body_reports_handler_error() {
264
317
  let (tx, rx) = mpsc::channel(4);
265
318
  let mut body = response_body(rx, Arc::new(ServerStats::default()));
266
- tx.try_send(StreamMessage::End(Some((7, "denied".into())))).ok();
319
+ tx.try_send(StreamMessage::error(Code::PermissionDenied, "denied"))
320
+ .ok();
267
321
  let t = trailers(poll(&mut body));
268
322
  assert_eq!(t["grpc-status"], "7");
269
323
  assert_eq!(t["grpc-message"], "denied");
270
324
  }
271
325
 
326
+ #[test]
327
+ fn response_body_starts_with_a_pending_message() {
328
+ let (tx, rx) = mpsc::channel(4);
329
+ let mut body = response_body(rx, Arc::new(ServerStats::default()));
330
+ body.pending = Some(StreamMessage::Data(b"x".to_vec()));
331
+ tx.try_send(StreamMessage::error(Code::Aborted, "stop")).ok();
332
+ assert!(matches!(poll(&mut body), Poll::Ready(Some(Ok(f))) if f.is_data()));
333
+ assert_eq!(trailers(poll(&mut body))["grpc-status"], "10");
334
+ }
335
+
272
336
  #[test]
273
337
  fn response_body_closed_channel_is_internal() {
274
338
  let (tx, rx) = mpsc::channel(4);
@@ -21,10 +21,12 @@ 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
- use crate::framing::{MessageReader, parse_grpc_timeout};
27
- use crate::gvl::{DispatchContext, RequestError, StreamMessage};
27
+ use crate::framing::{MessageReader, append_metadata, parse_grpc_timeout};
28
+ use crate::gvl::{DispatchContext, Peer, RequestError, StreamMessage};
29
+ use crate::log::Level;
28
30
 
29
31
  /// Messages buffered per direction before backpressure applies.
30
32
  const STREAM_BUFFER: usize = 32;
@@ -62,8 +64,8 @@ impl tower::Service<Request<axum::body::Body>> for DispatchService {
62
64
  }
63
65
 
64
66
  fn call(&mut self, req: Request<axum::body::Body>) -> Self::Future {
65
- let response = self.0.handle(req);
66
- Box::pin(async move { Ok(response) })
67
+ let inner = self.0.clone();
68
+ Box::pin(async move { Ok(inner.handle(req).await) })
67
69
  }
68
70
  }
69
71
 
@@ -83,11 +85,11 @@ fn grpc_response(body: TonicBody, compressed: bool) -> Response<TonicBody> {
83
85
 
84
86
  impl DispatchInner {
85
87
  fn reject(&self, path: &str, start: Instant, code: Code, message: &str) -> Response<TonicBody> {
86
- self.stats.record(path, code as u32, message, start);
88
+ self.stats.record(path, code as u32, message, start, true);
87
89
  grpc_response(TonicBody::new(ErrorBody::new(code, message)), false)
88
90
  }
89
91
 
90
- fn handle(&self, req: Request<axum::body::Body>) -> Response<TonicBody> {
92
+ async fn handle(&self, req: Request<axum::body::Body>) -> Response<TonicBody> {
91
93
  let start = Instant::now();
92
94
  self.stats.total.fetch_add(1, Ordering::Relaxed);
93
95
  let path = req.uri().path().to_owned();
@@ -164,6 +166,8 @@ impl DispatchInner {
164
166
  response_tx,
165
167
  request_rx,
166
168
  request_error: request_error.clone(),
169
+ log: self.stats.log.clone(),
170
+ peer: peer_of(&req),
167
171
  });
168
172
 
169
173
  match self.request_tx.try_send(ctx) {
@@ -190,17 +194,72 @@ impl DispatchInner {
190
194
  gzip_request,
191
195
  ));
192
196
 
197
+ // Response headers wait for the handler's first output, so it can
198
+ // set initial metadata; unary calls send headers with the response
199
+ // as usual. The client's deadline applies while we wait.
200
+ let mut response_rx = response_rx;
201
+ let mut deadline = timeout.map(|t| Box::pin(tokio::time::sleep(t)));
202
+ let first = match deadline.as_mut() {
203
+ Some(deadline) => tokio::select! {
204
+ biased;
205
+ message = response_rx.recv() => Some(message),
206
+ _ = deadline.as_mut() => None,
207
+ },
208
+ None => Some(response_rx.recv().await),
209
+ };
210
+ let (headers, pending) = match first {
211
+ None => return self.reject(&path, start, Code::DeadlineExceeded, "deadline exceeded"),
212
+ Some(None) => {
213
+ return self.reject(&path, start, Code::Internal, "handler ended without a response");
214
+ }
215
+ Some(Some(StreamMessage::Headers(headers))) => (headers, None),
216
+ Some(Some(message)) => (Vec::new(), Some(message)),
217
+ };
218
+
193
219
  let body = ResponseBody {
220
+ pending,
194
221
  receiver: Some(response_rx),
195
222
  stats: self.stats.clone(),
196
223
  path,
197
224
  start,
198
225
  compress,
199
- deadline: timeout.map(|t| Box::pin(tokio::time::sleep(t))),
226
+ deadline,
200
227
  max_message_size,
201
228
  finished: false,
202
229
  };
203
- grpc_response(TonicBody::new(body), compress)
230
+ let path = body.path.clone();
231
+ let mut response = grpc_response(TonicBody::new(body), compress);
232
+ let skipped = append_metadata(response.headers_mut(), &headers);
233
+ if !skipped.is_empty() {
234
+ self.stats.log.log(
235
+ Level::Warn,
236
+ &format!(
237
+ "gRPC {path} dropped invalid or reserved response headers: {}",
238
+ skipped.join(", ")
239
+ ),
240
+ );
241
+ }
242
+ response
243
+ }
244
+ }
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,
204
263
  }
205
264
  }
206
265
 
@@ -190,6 +190,35 @@ pub fn sanitize_for_log(message: &str) -> String {
190
190
  .collect()
191
191
  }
192
192
 
193
+ /// Headers a handler may not set: they belong to HTTP/2 or the gRPC
194
+ /// protocol itself.
195
+ fn is_reserved_metadata(name: &str) -> bool {
196
+ matches!(
197
+ name,
198
+ "content-type" | "te" | "user-agent" | "content-length" | "connection" | "transfer-encoding"
199
+ ) || (name.starts_with("grpc-") && name != "grpc-status-details-bin")
200
+ }
201
+
202
+ /// Append handler-supplied metadata to `headers`, skipping names or values
203
+ /// that are invalid HTTP/2 headers or reserved by gRPC. Returns the names
204
+ /// that were skipped so the caller can report them.
205
+ pub fn append_metadata(headers: &mut HeaderMap, metadata: &[(String, String)]) -> Vec<String> {
206
+ let mut skipped = Vec::new();
207
+ for (name, value) in metadata {
208
+ let header = (!is_reserved_metadata(name))
209
+ .then(|| http::HeaderName::from_bytes(name.as_bytes()).ok())
210
+ .flatten()
211
+ .filter(|n| n.as_str() == name.as_str()); // must already be lower-case
212
+ match (header, HeaderValue::from_str(value)) {
213
+ (Some(header), Ok(value)) => {
214
+ headers.append(header, value);
215
+ }
216
+ _ => skipped.push(name.clone()),
217
+ }
218
+ }
219
+ skipped
220
+ }
221
+
193
222
  pub fn status_trailers(code: u32, message: &str) -> HeaderMap {
194
223
  let mut trailers = HeaderMap::with_capacity(2);
195
224
  trailers.insert("grpc-status", HeaderValue::from(code));
@@ -342,6 +371,27 @@ mod tests {
342
371
  assert!(t.get("grpc-message").is_none());
343
372
  }
344
373
 
374
+ #[test]
375
+ fn append_metadata_skips_reserved_and_invalid() {
376
+ let mut headers = HeaderMap::new();
377
+ let skipped = append_metadata(
378
+ &mut headers,
379
+ &[
380
+ ("x-request-id".into(), "abc".into()),
381
+ ("x-trace-bin".into(), "AAE=".into()),
382
+ ("grpc-status-details-bin".into(), "CAU=".into()),
383
+ ("grpc-status".into(), "0".into()),
384
+ ("content-type".into(), "text/plain".into()),
385
+ ("Upper".into(), "x".into()),
386
+ ("x-bad".into(), "line\nbreak".into()),
387
+ ],
388
+ );
389
+ assert_eq!(headers["x-request-id"], "abc");
390
+ assert_eq!(headers["x-trace-bin"], "AAE=");
391
+ assert_eq!(headers["grpc-status-details-bin"], "CAU=");
392
+ assert_eq!(skipped, ["grpc-status", "content-type", "Upper", "x-bad"]);
393
+ }
394
+
345
395
  #[test]
346
396
  fn sanitize_for_log_strips_controls() {
347
397
  assert_eq!(sanitize_for_log("a\nb\tc"), "a b c");