protocol-grpc 0.1.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.
data/design.md ADDED
@@ -0,0 +1,1675 @@
1
+ # Protocol::GRPC Design
2
+
3
+ Protocol abstractions for gRPC, built on top of `protocol-http`.
4
+
5
+ ## Overview
6
+
7
+ gRPC is an RPC framework that runs over HTTP/2. It uses Protocol Buffers for serialization and supports four types of RPC patterns:
8
+
9
+ 1. **Unary RPC**: Single request, single response
10
+ 2. **Client Streaming**: Stream of requests, single response
11
+ 3. **Server Streaming**: Single request, stream of responses
12
+ 4. **Bidirectional Streaming**: Stream of requests, stream of responses
13
+
14
+ ## Architecture
15
+
16
+ Following the patterns from `Protocol::HTTP`, Protocol::GRPC provides **protocol-level abstractions only** - no networking, no client/server implementations. Those should be built on top in separate gems (e.g., `async-grpc`).
17
+
18
+ The protocol layer describes:
19
+ - How to frame gRPC messages (length-prefixed format)
20
+ - How to encode/decode gRPC metadata and trailers
21
+ - Status codes and error handling
22
+ - Request/response structure
23
+
24
+ It does NOT include:
25
+ - Actual network I/O
26
+ - Connection management
27
+ - Client or server implementations
28
+ - Concurrency primitives
29
+
30
+ ### Core Components Summary
31
+
32
+ The protocol layer provides these core abstractions:
33
+
34
+ 1. **Message Interface** - `Protocol::GRPC::Message` and `MessageHelpers`
35
+ 2. **Path Handling** - `Protocol::GRPC::Methods` (build/parse paths, headers, timeouts)
36
+ 3. **Metadata** - `Protocol::GRPC::Metadata` (extract status, build trailers)
37
+ 4. **Body Framing** - `Protocol::GRPC::Body::Readable` and `Body::Writable`
38
+ 5. **Status Codes** - `Protocol::GRPC::Status` constants
39
+ 6. **Errors** - `Protocol::GRPC::Error` hierarchy
40
+ 7. **Call Context** - `Protocol::GRPC::Call` (deadline tracking, metadata)
41
+ 8. **Server Middleware** - `Protocol::GRPC::Middleware` (handles gRPC requests)
42
+ 9. **Health Check** - `Protocol::GRPC::HealthCheck` protocol
43
+ 10. **Code Generation** - `Protocol::GRPC::Generator` (parse .proto, generate stubs)
44
+
45
+ **Key Design Principles:**
46
+ - gRPC bodies are **always** message-framed (never raw bytes)
47
+ - Use `Body::Readable` not `Body::Message` (it's the standard for gRPC)
48
+ - Use `Body::Writable` not `Body::MessageWriter` (it's the standard for gRPC)
49
+ - Compression is built-in via `encoding:` parameter (not separate wrapper)
50
+ - Binary mode: pass `message_class: nil` to work with raw bytes
51
+
52
+ ### Detailed Components
53
+
54
+ #### 1. `Protocol::GRPC::Message`
55
+
56
+ Interface for protobuf messages. Any protobuf implementation can conform to this:
57
+
58
+ ```ruby
59
+ module Protocol
60
+ module GRPC
61
+ # Defines the interface that protobuf messages should implement
62
+ # to work with Protocol::GRPC body encoding/decoding.
63
+ #
64
+ # Google's protobuf-ruby gem already provides these methods on generated classes:
65
+ # - MyMessage.decode(binary_string)
66
+ # - message_instance.to_proto
67
+ module Message
68
+ # Decode a binary protobuf string into a message instance
69
+ # @parameter data [String] Binary protobuf data
70
+ # @returns [Object] Decoded message instance
71
+ def decode(data)
72
+ raise NotImplementedError
73
+ end
74
+
75
+ # Encode a message instance to binary protobuf format
76
+ # @returns [String] Binary protobuf data
77
+ def encode
78
+ raise NotImplementedError
79
+ end
80
+
81
+ # Alias for encode to match google-protobuf convention
82
+ alias to_proto encode
83
+ end
84
+
85
+ # Helper methods for working with protobuf messages
86
+ module MessageHelpers
87
+ # Check if a class/object supports the protobuf message interface
88
+ # @parameter klass [Class, Object] The class or instance to check
89
+ # @returns [Boolean]
90
+ def self.protobuf?(klass)
91
+ klass.respond_to?(:decode) && (klass.respond_to?(:encode) || klass.respond_to?(:to_proto))
92
+ end
93
+
94
+ # Encode a message using the appropriate method
95
+ # @parameter message [Object] Message instance
96
+ # @returns [String] Binary protobuf data
97
+ def self.encode(message)
98
+ if message.respond_to?(:to_proto)
99
+ message.to_proto
100
+ elsif message.respond_to?(:encode)
101
+ message.encode
102
+ else
103
+ raise ArgumentError, "Message must respond to :to_proto or :encode"
104
+ end
105
+ end
106
+
107
+ # Decode binary data using the message class
108
+ # @parameter klass [Class] Message class with decode method
109
+ # @parameter data [String] Binary protobuf data
110
+ # @returns [Object] Decoded message instance
111
+ def self.decode(klass, data)
112
+ unless klass.respond_to?(:decode)
113
+ raise ArgumentError, "Message class must respond to :decode"
114
+ end
115
+
116
+ klass.decode(data)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ ```
122
+
123
+ **Path of Least Resistance**: Google's `protobuf` gem already generates classes with `.decode(binary)` and `#to_proto` methods, so they work out of the box with no wrapper needed.
124
+
125
+ #### 2. `Protocol::GRPC::Methods`
126
+
127
+ Helper module for building gRPC-compatible HTTP requests:
128
+
129
+ ```ruby
130
+ module Protocol
131
+ module GRPC
132
+ module Methods
133
+ # Build gRPC path from service and method
134
+ # @parameter service [String] e.g., "my_service.Greeter"
135
+ # @parameter method [String] e.g., "SayHello"
136
+ # @returns [String] e.g., "/my_service.Greeter/SayHello"
137
+ def self.build_path(service, method)
138
+ "/#{service}/#{method}"
139
+ end
140
+
141
+ # Parse service and method from gRPC path
142
+ # @parameter path [String] e.g., "/my_service.Greeter/SayHello"
143
+ # @returns [Array(String, String)] [service, method]
144
+ def self.parse_path(path)
145
+ parts = path.split("/")
146
+ [parts[1], parts[2]]
147
+ end
148
+
149
+ # Build gRPC request headers
150
+ # @parameter metadata [Hash] Custom metadata key-value pairs
151
+ # @parameter timeout [Numeric] Optional timeout in seconds
152
+ # @returns [Protocol::HTTP::Headers]
153
+ def self.build_headers(metadata: {}, timeout: nil, content_type: "application/grpc+proto")
154
+ headers = Protocol::HTTP::Headers.new
155
+ headers["content-type"] = content_type
156
+ headers["te"] = "trailers"
157
+ headers["grpc-timeout"] = format_timeout(timeout) if timeout
158
+
159
+ metadata.each do |key, value|
160
+ # Binary headers end with -bin and are base64 encoded
161
+ if key.end_with?("-bin")
162
+ headers[key] = Base64.strict_encode64(value)
163
+ else
164
+ headers[key] = value.to_s
165
+ end
166
+ end
167
+
168
+ headers
169
+ end
170
+
171
+ # Extract metadata from gRPC headers
172
+ # @parameter headers [Protocol::HTTP::Headers]
173
+ # @returns [Hash] Metadata key-value pairs
174
+ def self.extract_metadata(headers)
175
+ metadata = {}
176
+
177
+ headers.each do |key, value|
178
+ # Skip reserved headers
179
+ next if key.start_with?("grpc-") || key == "content-type" || key == "te"
180
+
181
+ # Decode binary headers
182
+ if key.end_with?("-bin")
183
+ metadata[key] = Base64.strict_decode64(value)
184
+ else
185
+ metadata[key] = value
186
+ end
187
+ end
188
+
189
+ metadata
190
+ end
191
+
192
+ # Format timeout for grpc-timeout header
193
+ # @parameter timeout [Numeric] Timeout in seconds
194
+ # @returns [String] e.g., "1000m" for 1 second
195
+ def self.format_timeout(timeout)
196
+ # gRPC timeout format: value + unit (H=hours, M=minutes, S=seconds, m=milliseconds, u=microseconds, n=nanoseconds)
197
+ if timeout >= 3600
198
+ "#{(timeout / 3600).to_i}H"
199
+ elsif timeout >= 60
200
+ "#{(timeout / 60).to_i}M"
201
+ elsif timeout >= 1
202
+ "#{timeout.to_i}S"
203
+ elsif timeout >= 0.001
204
+ "#{(timeout * 1000).to_i}m"
205
+ elsif timeout >= 0.000001
206
+ "#{(timeout * 1_000_000).to_i}u"
207
+ else
208
+ "#{(timeout * 1_000_000_000).to_i}n"
209
+ end
210
+ end
211
+
212
+ # Parse grpc-timeout header value
213
+ # @parameter value [String] e.g., "1000m"
214
+ # @returns [Numeric] Timeout in seconds
215
+ def self.parse_timeout(value)
216
+ return nil unless value
217
+
218
+ amount = value[0...-1].to_i
219
+ unit = value[-1]
220
+
221
+ case unit
222
+ when "H" then amount * 3600
223
+ when "M" then amount * 60
224
+ when "S" then amount
225
+ when "m" then amount / 1000.0
226
+ when "u" then amount / 1_000_000.0
227
+ when "n" then amount / 1_000_000_000.0
228
+ else nil
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
234
+ ```
235
+
236
+ #### 3. `Protocol::GRPC::Header` and Metadata
237
+
238
+ gRPC-specific header policy and metadata handling:
239
+
240
+ ```ruby
241
+ module Protocol
242
+ module GRPC
243
+ module Header
244
+ # Header class for grpc-status (allowed in trailers)
245
+ class Status < Protocol::HTTP::Header::Split
246
+ def self.trailer?
247
+ true
248
+ end
249
+ end
250
+
251
+ # Header class for grpc-message (allowed in trailers)
252
+ class Message < Protocol::HTTP::Header::Split
253
+ def self.trailer?
254
+ true
255
+ end
256
+ end
257
+
258
+ # Base class for custom gRPC metadata (allowed in trailers)
259
+ class Metadata < Protocol::HTTP::Header::Split
260
+ def self.trailer?
261
+ true
262
+ end
263
+ end
264
+ end
265
+
266
+ # Custom header policy for gRPC
267
+ # Extends Protocol::HTTP::Headers::POLICY with gRPC-specific headers
268
+ HEADER_POLICY = Protocol::HTTP::Headers::POLICY.merge(
269
+ "grpc-status" => Header::Status,
270
+ "grpc-message" => Header::Message,
271
+ # By default, all other headers follow standard HTTP policy
272
+ # But gRPC allows most metadata to be sent as trailers
273
+ ).freeze
274
+
275
+ module Metadata
276
+ # Extract gRPC status from headers
277
+ # Note: In Protocol::HTTP::Headers, trailers are merged into the headers
278
+ # so users just access headers["grpc-status"] regardless of whether it
279
+ # was sent as an initial header or trailer.
280
+ #
281
+ # @parameter headers [Protocol::HTTP::Headers]
282
+ # @returns [Integer] Status code (0-16)
283
+ def self.extract_status(headers)
284
+ status = headers["grpc-status"]
285
+ status ? status.to_i : Status::UNKNOWN
286
+ end
287
+
288
+ # Extract gRPC status message from headers
289
+ # @parameter headers [Protocol::HTTP::Headers]
290
+ # @returns [String, nil] Status message
291
+ def self.extract_message(headers)
292
+ message = headers["grpc-message"]
293
+ message ? URI.decode_www_form_component(message) : nil
294
+ end
295
+
296
+ # Build headers with gRPC status and message
297
+ # @parameter status [Integer] gRPC status code
298
+ # @parameter message [String, nil] Optional status message
299
+ # @parameter policy [Hash] Header policy to use
300
+ # @returns [Protocol::HTTP::Headers]
301
+ def self.build_status_headers(status: Status::OK, message: nil, policy: HEADER_POLICY)
302
+ headers = Protocol::HTTP::Headers.new([], nil, policy: policy)
303
+ headers["grpc-status"] = status.to_s
304
+ headers["grpc-message"] = URI.encode_www_form_component(message) if message
305
+ headers
306
+ end
307
+
308
+ # Mark that trailers will follow (call after sending initial headers)
309
+ # @parameter headers [Protocol::HTTP::Headers]
310
+ # @returns [Protocol::HTTP::Headers]
311
+ def self.prepare_trailers!(headers)
312
+ headers.trailer!
313
+ headers
314
+ end
315
+
316
+ # Add status as trailers to existing headers
317
+ # @parameter headers [Protocol::HTTP::Headers]
318
+ # @parameter status [Integer] gRPC status code
319
+ # @parameter message [String, nil] Optional status message
320
+ def self.add_status_trailer!(headers, status: Status::OK, message: nil)
321
+ headers.trailer! unless headers.trailer?
322
+ headers["grpc-status"] = status.to_s
323
+ headers["grpc-message"] = URI.encode_www_form_component(message) if message
324
+ end
325
+
326
+ # Add status as initial headers (for trailers-only responses)
327
+ # @parameter headers [Protocol::HTTP::Headers]
328
+ # @parameter status [Integer] gRPC status code
329
+ # @parameter message [String, nil] Optional status message
330
+ def self.add_status_header!(headers, status: Status::OK, message: nil)
331
+ headers["grpc-status"] = status.to_s
332
+ headers["grpc-message"] = URI.encode_www_form_component(message) if message
333
+ end
334
+
335
+ # Build a trailers-only error response (no body, status in headers)
336
+ # @parameter status [Integer] gRPC status code
337
+ # @parameter message [String, nil] Optional status message
338
+ # @parameter policy [Hash] Header policy to use
339
+ # @returns [Protocol::HTTP::Response]
340
+ def self.build_trailers_only_response(status:, message: nil, policy: HEADER_POLICY)
341
+ headers = Protocol::HTTP::Headers.new([], nil, policy: policy)
342
+ headers["content-type"] = "application/grpc+proto"
343
+ add_status_header!(headers, status: status, message: message)
344
+
345
+ Protocol::HTTP::Response[200, headers, nil]
346
+ end
347
+ end
348
+ end
349
+ end
350
+ ```
351
+
352
+ #### 4. `Protocol::GRPC::Body::Readable`
353
+
354
+ Reads length-prefixed gRPC messages.
355
+
356
+ **Design Note:** Since gRPC always uses message framing (never raw HTTP body), we name this `Readable` not `Message`. This is the standard body type for gRPC responses.
357
+
358
+ ```ruby
359
+ module Protocol
360
+ module GRPC
361
+ module Body
362
+ # Reads length-prefixed gRPC messages from an HTTP body
363
+ # This is the standard readable body for gRPC - all gRPC responses use message framing
364
+ class Readable < Protocol::HTTP::Body::Wrapper
365
+ # @parameter body [Protocol::HTTP::Body::Readable] The underlying HTTP body
366
+ # @parameter message_class [Class, nil] Protobuf message class with .decode method
367
+ # If nil, returns raw binary data (useful for channel adapters)
368
+ # @parameter encoding [String, nil] Compression encoding (from grpc-encoding header)
369
+ def initialize(body, message_class: nil, encoding: nil)
370
+ super(body)
371
+ @message_class = message_class
372
+ @encoding = encoding
373
+ @buffer = String.new.force_encoding(Encoding::BINARY)
374
+ end
375
+
376
+ # Override read to return decoded messages instead of raw chunks
377
+ # This makes the wrapper transparent - users call .read and get messages
378
+ # @returns [Object | String | Nil] Decoded message, raw binary, or nil if stream ended
379
+ def read
380
+ # Read 5-byte prefix: 1 byte compression flag + 4 bytes length
381
+ prefix = read_exactly(5)
382
+ return nil unless prefix
383
+
384
+ compressed = prefix[0].unpack1("C") == 1
385
+ length = prefix[1..4].unpack1("N")
386
+
387
+ # Read the message body
388
+ data = read_exactly(length)
389
+ return nil unless data
390
+
391
+ # Decompress if needed
392
+ data = decompress(data) if compressed
393
+
394
+ # Decode using message class if provided, otherwise return binary
395
+ # This allows binary mode for channel adapters
396
+ if @message_class
397
+ MessageHelpers.decode(@message_class, data)
398
+ else
399
+ data # Return raw binary
400
+ end
401
+ end
402
+
403
+ # Standard Protocol::HTTP::Body::Readable#each now iterates messages
404
+ # No need for separate each_message method
405
+ # Inherited from Readable:
406
+ # def each
407
+ # return to_enum unless block_given?
408
+ #
409
+ # begin
410
+ # while message = self.read
411
+ # yield message
412
+ # end
413
+ # rescue => error
414
+ # raise
415
+ # ensure
416
+ # self.close(error)
417
+ # end
418
+ # end
419
+
420
+ private
421
+
422
+ def read_exactly(n)
423
+ while @buffer.bytesize < n
424
+ chunk = @body.read
425
+ return nil unless chunk
426
+ @buffer << chunk
427
+ end
428
+
429
+ data = @buffer[0...n]
430
+ @buffer = @buffer[n..-1]
431
+ data
432
+ end
433
+
434
+ def decompress(data)
435
+ # TODO: Implement gzip decompression
436
+ data
437
+ end
438
+
439
+ def close(error = nil)
440
+ @body&.close(error)
441
+ end
442
+ end
443
+
444
+ # Writes length-prefixed gRPC messages
445
+ # This is the standard writable body for gRPC - all gRPC requests use message framing
446
+ class Writable < Protocol::HTTP::Body::Writable
447
+ # @parameter encoding [String, nil] Compression encoding (gzip, deflate, identity)
448
+ # @parameter level [Integer] Compression level if encoding is used
449
+ def initialize(encoding: nil, level: Zlib::DEFAULT_COMPRESSION, **options)
450
+ super(**options)
451
+ @encoding = encoding
452
+ @level = level
453
+ end
454
+
455
+ attr :encoding
456
+
457
+ # Write a message with gRPC framing
458
+ # @parameter message [Object, String] Protobuf message instance or raw binary data
459
+ # @parameter compressed [Boolean] Whether to compress this specific message
460
+ def write(message, compressed: nil)
461
+ # Encode message to binary if it's not already a string
462
+ # This supports both high-level (protobuf objects) and low-level (binary) usage
463
+ data = if message.is_a?(String)
464
+ message # Already binary, use as-is (for channel adapters)
465
+ else
466
+ MessageHelpers.encode(message) # Encode protobuf object
467
+ end
468
+
469
+ # Determine if we should compress this message
470
+ # If compressed param is nil, use the encoding setting
471
+ should_compress = compressed.nil? ? (@encoding && @encoding != "identity") : compressed
472
+
473
+ # Compress if requested
474
+ data = compress(data) if should_compress
475
+
476
+ # Build prefix: compression flag + length
477
+ compression_flag = should_compress ? 1 : 0
478
+ length = data.bytesize
479
+ prefix = [compression_flag].pack("C") + [length].pack("N")
480
+
481
+ # Write prefix + data to underlying body
482
+ super(prefix + data) # Call Protocol::HTTP::Body::Writable#write
483
+ end
484
+
485
+ protected
486
+
487
+ def compress(data)
488
+ case @encoding
489
+ when "gzip"
490
+ require "zlib"
491
+ io = StringIO.new
492
+ gz = Zlib::GzipWriter.new(io, @level)
493
+ gz.write(data)
494
+ gz.close
495
+ io.string
496
+ when "deflate"
497
+ require "zlib"
498
+ Zlib::Deflate.deflate(data, @level)
499
+ else
500
+ data # No compression or identity
501
+ end
502
+ end
503
+ end
504
+ end
505
+ end
506
+ end
507
+ ```
508
+
509
+ #### 5. `Protocol::GRPC::Status`
510
+
511
+ gRPC status codes (as constants):
512
+
513
+ ```ruby
514
+ module Protocol
515
+ module GRPC
516
+ module Status
517
+ OK = 0
518
+ CANCELLED = 1
519
+ UNKNOWN = 2
520
+ INVALID_ARGUMENT = 3
521
+ DEADLINE_EXCEEDED = 4
522
+ NOT_FOUND = 5
523
+ ALREADY_EXISTS = 6
524
+ PERMISSION_DENIED = 7
525
+ RESOURCE_EXHAUSTED = 8
526
+ FAILED_PRECONDITION = 9
527
+ ABORTED = 10
528
+ OUT_OF_RANGE = 11
529
+ UNIMPLEMENTED = 12
530
+ INTERNAL = 13
531
+ UNAVAILABLE = 14
532
+ DATA_LOSS = 15
533
+ UNAUTHENTICATED = 16
534
+
535
+ # Status code descriptions
536
+ DESCRIPTIONS = {
537
+ OK => "OK",
538
+ CANCELLED => "Cancelled",
539
+ UNKNOWN => "Unknown",
540
+ INVALID_ARGUMENT => "Invalid Argument",
541
+ DEADLINE_EXCEEDED => "Deadline Exceeded",
542
+ NOT_FOUND => "Not Found",
543
+ ALREADY_EXISTS => "Already Exists",
544
+ PERMISSION_DENIED => "Permission Denied",
545
+ RESOURCE_EXHAUSTED => "Resource Exhausted",
546
+ FAILED_PRECONDITION => "Failed Precondition",
547
+ ABORTED => "Aborted",
548
+ OUT_OF_RANGE => "Out of Range",
549
+ UNIMPLEMENTED => "Unimplemented",
550
+ INTERNAL => "Internal",
551
+ UNAVAILABLE => "Unavailable",
552
+ DATA_LOSS => "Data Loss",
553
+ UNAUTHENTICATED => "Unauthenticated"
554
+ }.freeze
555
+ end
556
+ end
557
+ end
558
+ ```
559
+
560
+ #### 6. `Protocol::GRPC::Call`
561
+
562
+ Represents a single RPC call with metadata and deadline tracking:
563
+
564
+ ```ruby
565
+ module Protocol
566
+ module GRPC
567
+ # Represents context for a single RPC call
568
+ class Call
569
+ # @parameter request [Protocol::HTTP::Request] The HTTP request
570
+ # @parameter deadline [Time, nil] Absolute deadline for the call
571
+ def initialize(request, deadline: nil)
572
+ @request = request
573
+ @deadline = deadline
574
+ @cancelled = false
575
+ end
576
+
577
+ # @attribute [Protocol::HTTP::Request] The underlying HTTP request
578
+ attr :request
579
+
580
+ # @attribute [Time, nil] The deadline for this call
581
+ attr :deadline
582
+
583
+ # Extract metadata from request headers
584
+ # @returns [Hash] Custom metadata
585
+ def metadata
586
+ @metadata ||= Methods.extract_metadata(@request.headers)
587
+ end
588
+
589
+ # Check if the deadline has expired
590
+ # @returns [Boolean]
591
+ def deadline_exceeded?
592
+ @deadline && Time.now > @deadline
593
+ end
594
+
595
+ # Time remaining until deadline
596
+ # @returns [Numeric, nil] Seconds remaining, or nil if no deadline
597
+ def time_remaining
598
+ @deadline ? [@deadline - Time.now, 0].max : nil
599
+ end
600
+
601
+ # Mark this call as cancelled
602
+ def cancel!
603
+ @cancelled = true
604
+ end
605
+
606
+ # Check if call was cancelled
607
+ # @returns [Boolean]
608
+ def cancelled?
609
+ @cancelled
610
+ end
611
+
612
+ # Get peer information (client address)
613
+ # @returns [String, nil]
614
+ def peer
615
+ @request.peer&.to_s
616
+ end
617
+ end
618
+ end
619
+ end
620
+ ```
621
+
622
+ #### 7. `Protocol::GRPC::Error`
623
+
624
+ Exception hierarchy for gRPC errors:
625
+
626
+ ```ruby
627
+ module Protocol
628
+ module GRPC
629
+ class Error < StandardError
630
+ attr_reader :status_code, :details, :metadata
631
+
632
+ def initialize(status_code, message = nil, details: nil, metadata: {})
633
+ @status_code = status_code
634
+ @details = details
635
+ @metadata = metadata
636
+ super(message || Status::DESCRIPTIONS[status_code])
637
+ end
638
+ end
639
+
640
+ # Specific error classes for common status codes
641
+ class Cancelled < Error
642
+ def initialize(message = nil, **options)
643
+ super(Status::CANCELLED, message, **options)
644
+ end
645
+ end
646
+
647
+ class InvalidArgument < Error
648
+ def initialize(message = nil, **options)
649
+ super(Status::INVALID_ARGUMENT, message, **options)
650
+ end
651
+ end
652
+
653
+ class DeadlineExceeded < Error
654
+ def initialize(message = nil, **options)
655
+ super(Status::DEADLINE_EXCEEDED, message, **options)
656
+ end
657
+ end
658
+
659
+ class NotFound < Error
660
+ def initialize(message = nil, **options)
661
+ super(Status::NOT_FOUND, message, **options)
662
+ end
663
+ end
664
+
665
+ class Internal < Error
666
+ def initialize(message = nil, **options)
667
+ super(Status::INTERNAL, message, **options)
668
+ end
669
+ end
670
+
671
+ class Unavailable < Error
672
+ def initialize(message = nil, **options)
673
+ super(Status::UNAVAILABLE, message, **options)
674
+ end
675
+ end
676
+
677
+ class Unauthenticated < Error
678
+ def initialize(message = nil, **options)
679
+ super(Status::UNAUTHENTICATED, message, **options)
680
+ end
681
+ end
682
+ end
683
+ end
684
+ ```
685
+
686
+ ## Compression in gRPC
687
+
688
+ **Key Difference from HTTP:**
689
+ - **HTTP**: Entire body is compressed (`content-encoding: gzip`)
690
+ - **gRPC**: Each message is individually compressed (per-message compression flag)
691
+ - `grpc-encoding` header indicates the algorithm used
692
+ - Each message's byte 0 (compression flag) indicates if that specific message is compressed
693
+
694
+ **Design Decision:** Since gRPC bodies are ALWAYS message-framed, compression is built into `Readable` and `Writable`. No separate wrapper needed - just pass the `encoding` parameter.
695
+
696
+ ```ruby
697
+ # Compression is built-in, controlled by encoding parameter
698
+ body = Protocol::GRPC::Body::Writable.new(encoding: "gzip")
699
+ body.write(message) # Automatically compressed with prefix
700
+
701
+ # Reading automatically decompresses
702
+ encoding = response.headers["grpc-encoding"]
703
+ body = Protocol::GRPC::Body::Readable.new(
704
+ response.body,
705
+ message_class: MyReply,
706
+ encoding: encoding
707
+ )
708
+ message = body.read # Automatically decompressed
709
+ ```
710
+
711
+ This is cleaner than Protocol::HTTP's separate `Deflate`/`Inflate` wrappers because:
712
+ - gRPC always has message framing (never raw bytes)
713
+ - Compression is per-message (part of the framing protocol)
714
+ - No need for separate wrapper classes
715
+
716
+ #### 9. `Protocol::GRPC::Middleware`
717
+
718
+ Server middleware for handling gRPC requests:
719
+
720
+ ```ruby
721
+ module Protocol
722
+ module GRPC
723
+ # Server middleware for handling gRPC requests
724
+ # Implements Protocol::HTTP::Middleware interface
725
+ # This is the protocol-level server - no async, just request/response handling
726
+ class Middleware < Protocol::HTTP::Middleware
727
+ # @parameter app [#call] The next middleware in the chain
728
+ # @parameter services [Hash] Map of service name => service handler
729
+ def initialize(app = nil, services: {})
730
+ super(app)
731
+ @services = services
732
+ end
733
+
734
+ # Register a service handler
735
+ # @parameter service_name [String] Full service name, e.g., "my_service.Greeter"
736
+ # @parameter handler [Object] Service implementation
737
+ def register(service_name, handler)
738
+ @services[service_name] = handler
739
+ end
740
+
741
+ # Handle incoming HTTP request
742
+ # @parameter request [Protocol::HTTP::Request]
743
+ # @returns [Protocol::HTTP::Response]
744
+ def call(request)
745
+ # Check if this is a gRPC request
746
+ unless grpc_request?(request)
747
+ # Not a gRPC request, pass to next middleware
748
+ return super
749
+ end
750
+
751
+ # Parse service and method from path
752
+ service_name, method_name = Methods.parse_path(request.path)
753
+
754
+ # Find handler
755
+ handler = @services[service_name]
756
+ unless handler
757
+ return trailers_only_error(Status::UNIMPLEMENTED, "Service not found: #{service_name}")
758
+ end
759
+
760
+ # Determine handler method and message classes
761
+ rpc_desc = handler.class.respond_to?(:rpc_descriptions) ? handler.class.rpc_descriptions[method_name] : nil
762
+
763
+ if rpc_desc
764
+ # Use generated RPC descriptor
765
+ handler_method = rpc_desc[:method]
766
+ request_class = rpc_desc[:request_class]
767
+ response_class = rpc_desc[:response_class]
768
+ else
769
+ # Fallback to simple method name
770
+ handler_method = method_name.underscore.to_sym
771
+ request_class = nil
772
+ response_class = nil
773
+ end
774
+
775
+ unless handler.respond_to?(handler_method)
776
+ return trailers_only_error(Status::UNIMPLEMENTED, "Method not found: #{method_name}")
777
+ end
778
+
779
+ # Handle the RPC
780
+ begin
781
+ handle_rpc(request, handler, handler_method, request_class, response_class)
782
+ rescue Error => e
783
+ trailers_only_error(e.status_code, e.message)
784
+ rescue => e
785
+ trailers_only_error(Status::INTERNAL, e.message)
786
+ end
787
+ end
788
+
789
+ protected
790
+
791
+ def grpc_request?(request)
792
+ content_type = request.headers["content-type"]
793
+ content_type&.start_with?("application/grpc")
794
+ end
795
+
796
+ # Override in subclass to add async handling
797
+ def handle_rpc(request, handler, method, request_class, response_class)
798
+ # Create input/output streams
799
+ encoding = request.headers["grpc-encoding"]
800
+ input = Body::Readable.new(request.body, message_class: request_class, encoding: encoding)
801
+ output = Body::Writable.new(encoding: encoding)
802
+
803
+ # Create call context
804
+ response_headers = Protocol::HTTP::Headers.new([], nil, policy: HEADER_POLICY)
805
+ response_headers["content-type"] = "application/grpc+proto"
806
+ response_headers["grpc-encoding"] = encoding if encoding
807
+
808
+ call = Call.new(request)
809
+
810
+ # Invoke handler
811
+ handler.send(method, input, output, call)
812
+ output.close_write unless output.closed?
813
+
814
+ # Mark trailers and add status
815
+ response_headers.trailer!
816
+ Metadata.add_status_trailer!(response_headers, status: Status::OK)
817
+
818
+ Protocol::HTTP::Response[200, response_headers, output]
819
+ end
820
+
821
+ def trailers_only_error(status_code, message)
822
+ Metadata.build_trailers_only_response(
823
+ status: status_code,
824
+ message: message,
825
+ policy: HEADER_POLICY
826
+ )
827
+ end
828
+ end
829
+ end
830
+ end
831
+ ```
832
+
833
+ #### 10. `Protocol::GRPC::HealthCheck`
834
+
835
+ Standard health checking protocol:
836
+
837
+ ```ruby
838
+ module Protocol
839
+ module GRPC
840
+ module HealthCheck
841
+ # Health check status constants
842
+ module ServingStatus
843
+ UNKNOWN = 0
844
+ SERVING = 1
845
+ NOT_SERVING = 2
846
+ SERVICE_UNKNOWN = 3
847
+ end
848
+ end
849
+ end
850
+ end
851
+ ```
852
+
853
+ ## Usage Examples
854
+
855
+ ### Building a gRPC Request (Protocol Layer Only)
856
+
857
+ ```ruby
858
+ require "protocol/grpc"
859
+ require "protocol/http"
860
+
861
+ # Create request body with protobuf messages
862
+ body = Protocol::GRPC::Body::Writable.new
863
+ body.write(MyService::HelloRequest.new(name: "World"))
864
+ body.close_write
865
+
866
+ # Build gRPC headers
867
+ headers = Protocol::GRPC::Methods.build_headers(
868
+ metadata: {"authorization" => "Bearer token123"},
869
+ timeout: 5.0
870
+ )
871
+
872
+ # Create HTTP request with gRPC path
873
+ path = Protocol::GRPC::Methods.build_path("my_service.Greeter", "SayHello")
874
+
875
+ request = Protocol::HTTP::Request[
876
+ "POST", path,
877
+ headers: headers,
878
+ body: body,
879
+ scheme: "https",
880
+ authority: "localhost:50051"
881
+ ]
882
+
883
+ # Request is now ready to be sent via any HTTP/2 client
884
+ # (e.g., Async::HTTP::Client, or any other Protocol::HTTP-compatible client)
885
+ ```
886
+
887
+ ### Reading a gRPC Response (Protocol Layer Only)
888
+
889
+ ```ruby
890
+ require "protocol/grpc"
891
+
892
+ # Assume we got an HTTP response from somewhere
893
+ # http_response = client.call(request)
894
+
895
+ # Read protobuf messages from response body
896
+ message_body = Protocol::GRPC::Body::Readable.new(
897
+ http_response.body,
898
+ message_class: MyService::HelloReply
899
+ )
900
+
901
+ # Read single message (unary RPC)
902
+ reply = message_body.read
903
+ puts reply.message
904
+
905
+ # Or iterate over multiple messages (server streaming)
906
+ message_body.each do |reply|
907
+ puts reply.message
908
+ end
909
+
910
+ # Extract gRPC status from trailers
911
+ status = Protocol::GRPC::Metadata.extract_status(http_response.headers)
912
+ if status != Protocol::GRPC::Status::OK
913
+ message = Protocol::GRPC::Metadata.extract_message(http_response.headers)
914
+ raise Protocol::GRPC::Error.new(status, message)
915
+ end
916
+ ```
917
+
918
+ ### Server: Handling a gRPC Request (Protocol Layer Only)
919
+
920
+ ```ruby
921
+ require "protocol/grpc"
922
+
923
+ # This would be inside a Rack/HTTP middleware/handler
924
+ def handle_grpc_request(http_request)
925
+ # Parse gRPC path
926
+ service, method = Protocol::GRPC::Methods.parse_path(http_request.path)
927
+
928
+ # Read input messages
929
+ input = Protocol::GRPC::Body::Readable.new(
930
+ http_request.body,
931
+ message_class: MyService::HelloRequest
932
+ )
933
+
934
+ request_message = input.read
935
+
936
+ # Process the request
937
+ reply = MyService::HelloReply.new(
938
+ message: "Hello, #{request_message.name}!"
939
+ )
940
+
941
+ # Create response body
942
+ output = Protocol::GRPC::Body::Writable.new
943
+ output.write(reply)
944
+ output.close_write
945
+
946
+ # Build response headers with gRPC policy
947
+ headers = Protocol::HTTP::Headers.new([], nil, policy: Protocol::GRPC::HEADER_POLICY)
948
+ headers["content-type"] = "application/grpc+proto"
949
+
950
+ # Mark that trailers will follow (after body)
951
+ headers.trailer!
952
+
953
+ # Add status as trailer - these will be sent after the response body
954
+ # Note: The user just adds them to headers; the @tail marker ensures
955
+ # they're recognized as trailers internally
956
+ Protocol::GRPC::Metadata.add_status_trailer!(headers, status: Protocol::GRPC::Status::OK)
957
+
958
+ Protocol::HTTP::Response[200, headers, output]
959
+ end
960
+ ```
961
+
962
+ ### Working with Protobuf Messages
963
+
964
+ ```ruby
965
+ require "protocol/grpc"
966
+ require_relative "my_service_pb" # Generated by protoc
967
+
968
+ # Google's protobuf gem generates classes that work automatically:
969
+ # - MyService::HelloRequest has .decode(binary) class method
970
+ # - message instances have #to_proto instance method
971
+
972
+ # Check if a class is compatible
973
+ Protocol::GRPC::MessageHelpers.protobuf?(MyService::HelloRequest) # => true
974
+
975
+ # Encode a message
976
+ request = MyService::HelloRequest.new(name: "World")
977
+ binary = Protocol::GRPC::MessageHelpers.encode(request)
978
+
979
+ # Decode a message
980
+ decoded = Protocol::GRPC::MessageHelpers.decode(MyService::HelloRequest, binary)
981
+
982
+ # These helpers allow the protocol layer to work with any protobuf
983
+ # implementation that provides .decode and #to_proto / #encode methods
984
+ ```
985
+
986
+ ### Understanding Trailers in gRPC
987
+
988
+ ```ruby
989
+ require "protocol/grpc"
990
+
991
+ # Example 1: Normal Response (status in trailers)
992
+ # Create headers with gRPC policy (required for trailer support)
993
+ headers = Protocol::HTTP::Headers.new([], nil, policy: Protocol::GRPC::HEADER_POLICY)
994
+
995
+ # Add initial headers
996
+ headers["content-type"] = "application/grpc+proto"
997
+ headers["custom-metadata"] = "initial-value"
998
+
999
+ # Mark the boundary: everything added after this is a trailer
1000
+ headers.trailer!
1001
+
1002
+ # Add trailers (sent after response body)
1003
+ headers["grpc-status"] = "0"
1004
+ headers["grpc-message"] = "OK"
1005
+ headers["custom-trailer"] = "final-value"
1006
+
1007
+ # From the user perspective, both headers and trailers are accessed the same way:
1008
+ headers["content-type"] # => "application/grpc+proto"
1009
+ headers["grpc-status"] # => "0"
1010
+ headers["custom-trailer"] # => "final-value"
1011
+
1012
+ # But internally, Protocol::HTTP::Headers knows which are trailers:
1013
+ headers.trailer? # => true
1014
+ headers.tail # => 2 (index where trailers start)
1015
+
1016
+ # Iterate over just the trailers:
1017
+ headers.trailer.each do |key, value|
1018
+ puts "#{key}: #{value}"
1019
+ end
1020
+ # Outputs:
1021
+ # grpc-status: 0
1022
+ # grpc-message: OK
1023
+ # custom-trailer: final-value
1024
+
1025
+ # The gRPC header policy ensures grpc-status and grpc-message are allowed as trailers
1026
+ # Without the policy, they would be rejected when added after trailer!()
1027
+ ```
1028
+
1029
+ ```ruby
1030
+ # Example 2: Trailers-Only Response (immediate error, status in headers)
1031
+ # This is used when you have an error before sending any response body
1032
+
1033
+ headers = Protocol::HTTP::Headers.new([], nil, policy: Protocol::GRPC::HEADER_POLICY)
1034
+
1035
+ # Add initial headers
1036
+ headers["content-type"] = "application/grpc+proto"
1037
+
1038
+ # Add status directly as headers (NOT trailers) - no need to call trailer!
1039
+ headers["grpc-status"] = Protocol::GRPC::Status::NOT_FOUND.to_s
1040
+ headers["grpc-message"] = URI.encode_www_form_component("User not found")
1041
+
1042
+ # No trailer!() call, no body
1043
+ Protocol::HTTP::Response[200, headers, nil]
1044
+
1045
+ # This is a "trailers-only" response - the status is sent immediately
1046
+ # without any response body. This is semantically equivalent to sending
1047
+ # trailers, but more efficient when there's no data to send.
1048
+ ```
1049
+
1050
+ ## Code Generation
1051
+
1052
+ `protocol-grpc` includes a simple code generator for service definitions.
1053
+
1054
+ ### Philosophy
1055
+
1056
+ - **Use standard `protoc` for messages**: Don't reinvent Protocol Buffers serialization
1057
+ - **Generate service layer only**: Parse `.proto` files to extract service definitions
1058
+ - **Generate Async::GRPC-compatible code**: Client stubs and server base classes
1059
+ - **Minimal dependencies**: Simple text parsing, no need for full protobuf compiler
1060
+
1061
+ ### Generator API
1062
+
1063
+ ```ruby
1064
+ require "protocol/grpc/generator"
1065
+
1066
+ # Generate from .proto file
1067
+ generator = Protocol::GRPC::Generator.new("my_service.proto")
1068
+
1069
+ # Generate client stubs
1070
+ generator.generate_client("lib/my_service_client.rb")
1071
+
1072
+ # Generate server base classes
1073
+ generator.generate_server("lib/my_service_server.rb")
1074
+
1075
+ # Or generate both
1076
+ generator.generate_all("lib/my_service_grpc.rb")
1077
+ ```
1078
+
1079
+ ### Example: Input .proto File
1080
+
1081
+ ```protobuf
1082
+ syntax = "proto3";
1083
+
1084
+ package my_service;
1085
+
1086
+ message HelloRequest {
1087
+ string name = 1;
1088
+ }
1089
+
1090
+ message HelloReply {
1091
+ string message = 1;
1092
+ }
1093
+
1094
+ message Point {
1095
+ int32 latitude = 1;
1096
+ int32 longitude = 2;
1097
+ }
1098
+
1099
+ message RouteSummary {
1100
+ int32 point_count = 1;
1101
+ }
1102
+
1103
+ service Greeter {
1104
+ // Unary RPC
1105
+ rpc SayHello(HelloRequest) returns (HelloReply);
1106
+
1107
+ // Server streaming RPC
1108
+ rpc StreamNumbers(HelloRequest) returns (stream HelloReply);
1109
+
1110
+ // Client streaming RPC
1111
+ rpc RecordRoute(stream Point) returns (RouteSummary);
1112
+
1113
+ // Bidirectional streaming RPC
1114
+ rpc RouteChat(stream Point) returns (stream Point);
1115
+ }
1116
+ ```
1117
+
1118
+ ### Generated Client Stub
1119
+
1120
+ ```ruby
1121
+ # Generated by Protocol::GRPC::Generator
1122
+ # DO NOT EDIT
1123
+
1124
+ require "protocol/grpc"
1125
+ require_relative "my_service_pb" # Generated by protoc --ruby_out
1126
+
1127
+ module MyService
1128
+ # Client stub for Greeter service
1129
+ class GreeterClient
1130
+ # @parameter client [Async::GRPC::Client] The gRPC client
1131
+ def initialize(client)
1132
+ @client = client
1133
+ end
1134
+
1135
+ SERVICE_PATH = "my_service.Greeter"
1136
+
1137
+ # Unary RPC: SayHello
1138
+ # @parameter request [MyService::HelloRequest]
1139
+ # @parameter metadata [Hash] Custom metadata
1140
+ # @parameter timeout [Numeric] Deadline
1141
+ # @returns [MyService::HelloReply]
1142
+ def say_hello(request, metadata: {}, timeout: nil)
1143
+ @client.unary(
1144
+ SERVICE_PATH,
1145
+ "SayHello",
1146
+ request,
1147
+ response_class: MyService::HelloReply,
1148
+ metadata: metadata,
1149
+ timeout: timeout
1150
+ )
1151
+ end
1152
+
1153
+ # Server streaming RPC: StreamNumbers
1154
+ # @parameter request [MyService::HelloRequest]
1155
+ # @yields {|response| ...} Each HelloReply message
1156
+ # @returns [Enumerator<MyService::HelloReply>] if no block given
1157
+ def stream_numbers(request, metadata: {}, timeout: nil, &block)
1158
+ @client.server_streaming(
1159
+ SERVICE_PATH,
1160
+ "StreamNumbers",
1161
+ request,
1162
+ response_class: MyService::HelloReply,
1163
+ metadata: metadata,
1164
+ timeout: timeout,
1165
+ &block
1166
+ )
1167
+ end
1168
+
1169
+ # Client streaming RPC: RecordRoute
1170
+ # @yields {|stream| ...} Block that writes Point messages
1171
+ # @returns [MyService::RouteSummary]
1172
+ def record_route(metadata: {}, timeout: nil, &block)
1173
+ @client.client_streaming(
1174
+ SERVICE_PATH,
1175
+ "RecordRoute",
1176
+ response_class: MyService::RouteSummary,
1177
+ metadata: metadata,
1178
+ timeout: timeout,
1179
+ &block
1180
+ )
1181
+ end
1182
+
1183
+ # Bidirectional streaming RPC: RouteChat
1184
+ # @yields {|input, output| ...} input for writing, output for reading
1185
+ def route_chat(metadata: {}, timeout: nil, &block)
1186
+ @client.bidirectional_streaming(
1187
+ SERVICE_PATH,
1188
+ "RouteChat",
1189
+ response_class: MyService::Point,
1190
+ metadata: metadata,
1191
+ timeout: timeout,
1192
+ &block
1193
+ )
1194
+ end
1195
+ end
1196
+ end
1197
+ ```
1198
+
1199
+ ### Generated Server Base Class
1200
+
1201
+ ```ruby
1202
+ # Generated by Protocol::GRPC::Generator
1203
+ # DO NOT EDIT
1204
+
1205
+ require "protocol/grpc"
1206
+ require_relative "my_service_pb" # Generated by protoc --ruby_out
1207
+
1208
+ module MyService
1209
+ # Base class for Greeter service implementation
1210
+ # Inherit from this class and implement the RPC methods
1211
+ class GreeterService
1212
+ # Unary RPC: SayHello
1213
+ # Override this method in your implementation
1214
+ # @parameter request [MyService::HelloRequest]
1215
+ # @parameter call [Protocol::GRPC::ServerCall] Call context with metadata
1216
+ # @returns [MyService::HelloReply]
1217
+ def say_hello(request, call)
1218
+ raise NotImplementedError, "#{self.class}#say_hello not implemented"
1219
+ end
1220
+
1221
+ # Server streaming RPC: StreamNumbers
1222
+ # Override this method in your implementation
1223
+ # @parameter request [MyService::HelloRequest]
1224
+ # @parameter call [Protocol::GRPC::ServerCall] Call context with metadata
1225
+ # @yields [MyService::HelloReply] Yield each response message
1226
+ def stream_numbers(request, call)
1227
+ raise NotImplementedError, "#{self.class}#stream_numbers not implemented"
1228
+ end
1229
+
1230
+ # Client streaming RPC: RecordRoute
1231
+ # Override this method in your implementation
1232
+ # @parameter call [Protocol::GRPC::ServerCall] Call context with metadata
1233
+ # @yields [MyService::Point] Each request message from client
1234
+ # @returns [MyService::RouteSummary]
1235
+ def record_route(call)
1236
+ raise NotImplementedError, "#{self.class}#record_route not implemented"
1237
+ end
1238
+
1239
+ # Bidirectional streaming RPC: RouteChat
1240
+ # Override this method in your implementation
1241
+ # @parameter call [Protocol::GRPC::ServerCall] Call context with metadata
1242
+ # @returns [Enumerator, Enumerator] (input, output) - input for reading, output for writing
1243
+ def route_chat(call)
1244
+ raise NotImplementedError, "#{self.class}#route_chat not implemented"
1245
+ end
1246
+
1247
+ # Internal: Dispatch method for Async::GRPC::Server
1248
+ # Maps RPC calls to handler methods
1249
+ def self.rpc_descriptions
1250
+ {
1251
+ "SayHello" => {
1252
+ method: :say_hello,
1253
+ request_class: MyService::HelloRequest,
1254
+ response_class: MyService::HelloReply,
1255
+ request_streaming: false,
1256
+ response_streaming: false
1257
+ },
1258
+ "StreamNumbers" => {
1259
+ method: :stream_numbers,
1260
+ request_class: MyService::HelloRequest,
1261
+ response_class: MyService::HelloReply,
1262
+ request_streaming: false,
1263
+ response_streaming: true
1264
+ },
1265
+ "RecordRoute" => {
1266
+ method: :record_route,
1267
+ request_class: MyService::Point,
1268
+ response_class: MyService::RouteSummary,
1269
+ request_streaming: true,
1270
+ response_streaming: false
1271
+ },
1272
+ "RouteChat" => {
1273
+ method: :route_chat,
1274
+ request_class: MyService::Point,
1275
+ response_class: MyService::Point,
1276
+ request_streaming: true,
1277
+ response_streaming: true
1278
+ }
1279
+ }
1280
+ end
1281
+ end
1282
+ end
1283
+ ```
1284
+
1285
+ ### Usage: Client Side
1286
+
1287
+ ```ruby
1288
+ require "async"
1289
+ require "async/grpc/client"
1290
+ require_relative "my_service_grpc"
1291
+
1292
+ endpoint = Async::HTTP::Endpoint.parse("https://localhost:50051")
1293
+
1294
+ Async do
1295
+ client = Async::GRPC::Client.new(endpoint)
1296
+ stub = MyService::GreeterClient.new(client)
1297
+
1298
+ # Clean, typed interface!
1299
+ request = MyService::HelloRequest.new(name: "World")
1300
+ response = stub.say_hello(request)
1301
+ puts response.message
1302
+
1303
+ # Server streaming
1304
+ stub.stream_numbers(request) do |reply|
1305
+ puts reply.message
1306
+ end
1307
+ ensure
1308
+ client.close
1309
+ end
1310
+ ```
1311
+
1312
+ ### Usage: Server Side
1313
+
1314
+ ```ruby
1315
+ require "async"
1316
+ require "async/grpc/server"
1317
+ require_relative "my_service_grpc"
1318
+
1319
+ # Implement the service by inheriting from generated base class
1320
+ class MyGreeter < MyService::GreeterService
1321
+ def say_hello(request, call)
1322
+ MyService::HelloReply.new(
1323
+ message: "Hello, #{request.name}!"
1324
+ )
1325
+ end
1326
+
1327
+ def stream_numbers(request, call)
1328
+ 10.times do |i|
1329
+ yield MyService::HelloReply.new(
1330
+ message: "Number #{i} for #{request.name}"
1331
+ )
1332
+ end
1333
+ end
1334
+
1335
+ def record_route(call)
1336
+ points = []
1337
+ call.each_request do |point|
1338
+ points << point
1339
+ end
1340
+
1341
+ MyService::RouteSummary.new(
1342
+ point_count: points.size
1343
+ )
1344
+ end
1345
+ end
1346
+
1347
+ # Register service
1348
+ Async do
1349
+ server = Async::GRPC::Server.new
1350
+ server.register("my_service.Greeter", MyGreeter.new)
1351
+
1352
+ # ... start server
1353
+ end
1354
+ ```
1355
+
1356
+ ### Generator Implementation
1357
+
1358
+ The generator would:
1359
+ 1. **Parse `.proto` files** using simple regex/text parsing (no full compiler needed)
1360
+ 2. **Extract service definitions**: service name, RPC methods, request/response types
1361
+ 3. **Determine streaming types**: unary, server streaming, client streaming, bidirectional
1362
+ 4. **Generate Ruby code** using ERB templates or string interpolation
1363
+
1364
+ Key classes:
1365
+ ```ruby
1366
+ module Protocol
1367
+ module GRPC
1368
+ class Generator
1369
+ # @parameter proto_file [String] Path to .proto file
1370
+ def initialize(proto_file)
1371
+ @proto = parse_proto(proto_file)
1372
+ end
1373
+
1374
+ def generate_client(output_path)
1375
+ # Generate client stub
1376
+ end
1377
+
1378
+ def generate_server(output_path)
1379
+ # Generate server base class
1380
+ end
1381
+
1382
+ private
1383
+
1384
+ def parse_proto(file)
1385
+ # Simple parsing - extract:
1386
+ # - package name
1387
+ # - message names (just reference them, protoc generates these)
1388
+ # - service definitions
1389
+ # - RPC methods with request/response types and streaming flags
1390
+ end
1391
+ end
1392
+ end
1393
+ end
1394
+ ```
1395
+
1396
+ ### Workflow
1397
+
1398
+ ```bash
1399
+ # Step 1: Generate message classes with standard protoc
1400
+ protoc --ruby_out=lib my_service.proto
1401
+
1402
+ # Step 2: Generate gRPC service layer with protocol-grpc using Bake
1403
+ bake protocol:grpc:generate my_service.proto
1404
+
1405
+ # Or generate from all .proto files in a directory
1406
+ bake protocol:grpc:generate:all
1407
+ ```
1408
+
1409
+ ### Bake Tasks
1410
+
1411
+ ```ruby
1412
+ # bake/protocol/grpc.rb
1413
+ module Bake
1414
+ module Protocol
1415
+ module GRPC
1416
+ # Generate gRPC service stubs from .proto file
1417
+ # @parameter path [String] Path to .proto file
1418
+ def generate(path)
1419
+ require "protocol/grpc/generator"
1420
+
1421
+ generator = ::Protocol::GRPC::Generator.new(path)
1422
+ output_path = path.sub(/\.proto$/, "_grpc.rb")
1423
+
1424
+ generator.generate_all(output_path)
1425
+
1426
+ Console.logger.info(self){"Generated #{output_path}"}
1427
+ end
1428
+
1429
+ # Generate gRPC stubs for all .proto files in directory
1430
+ # @parameter directory [String] Directory containing .proto files
1431
+ def generate_all(directory: ".")
1432
+ Dir.glob(File.join(directory, "**/*.proto")).each do |proto_file|
1433
+ generate(proto_file)
1434
+ end
1435
+ end
1436
+ end
1437
+ end
1438
+ end
1439
+ ```
1440
+
1441
+ Usage:
1442
+ ```bash
1443
+ # Generate from specific file
1444
+ bake protocol:grpc:generate sample/my_service.proto
1445
+
1446
+ # Generate all in directory
1447
+ bake protocol:grpc:generate:all
1448
+
1449
+ # Or from specific directory
1450
+ bake protocol:grpc:generate:all directory=protos/
1451
+ ```
1452
+
1453
+ This keeps dependencies minimal while providing great developer experience!
1454
+
1455
+ ## Implementation Roadmap
1456
+
1457
+ ### Phase 1: Core Protocol Primitives
1458
+ - `Protocol::GRPC::Message` interface (✅ Designed)
1459
+ - `Protocol::GRPC::MessageHelpers` for encoding/decoding (✅ Designed)
1460
+ - `Protocol::GRPC::Status` constants (✅ Designed)
1461
+ - `Protocol::GRPC::Error` hierarchy (✅ Designed)
1462
+ - `Protocol::GRPC::Body::Readable` (framing reader) (✅ Designed)
1463
+ - `Protocol::GRPC::Body::Writable` (framing writer) (✅ Designed)
1464
+ - Binary message support (no message_class = raw binary) (✅ Designed)
1465
+
1466
+ ### Phase 2: Protocol Helpers
1467
+ - `Protocol::GRPC::Methods` (path parsing, header building) (✅ Designed)
1468
+ - `Protocol::GRPC::Header` classes (Status, Message, Metadata) (✅ Designed)
1469
+ - `Protocol::GRPC::HEADER_POLICY` for trailer support (✅ Designed)
1470
+ - `Protocol::GRPC::Metadata` (status extraction, trailer helpers) (✅ Designed)
1471
+ - `Protocol::GRPC::Call` context object (✅ Designed)
1472
+ - `Protocol::GRPC::Middleware` server (✅ Designed)
1473
+ - Compression support (built into Readable/Writable) (✅ Designed)
1474
+ - `Protocol::GRPC::HealthCheck` protocol (✅ Designed)
1475
+ - Binary metadata support (base64 encoding for `-bin` headers) (✅ Designed)
1476
+ - Timeout format handling (✅ Designed)
1477
+
1478
+ ### Phase 3: Code Generation
1479
+ - `Protocol::GRPC::Generator` - Parse .proto files
1480
+ - Generate client stubs
1481
+ - Generate server base classes
1482
+ - CLI tool: `bake protocol:grpc:generate`
1483
+
1484
+ ### Phase 4: Advanced Protocol Features
1485
+ - Compression support (gzip)
1486
+ - Streaming body wrappers
1487
+ - Message validation helpers
1488
+
1489
+ ### Phase 5: Separate Gems (Not in protocol-grpc)
1490
+ - `async-grpc` - Async client implementation + helpers
1491
+ - No server class needed (just use Protocol::GRPC::Middleware with Async::HTTP::Server)
1492
+ - Channel adapter for Google Cloud integration
1493
+
1494
+ ## Design Decisions
1495
+
1496
+ ### Protocol Layer Only
1497
+
1498
+ This gem provides **only protocol abstractions**, not client/server implementations. This follows the same pattern as `protocol-http`:
1499
+ - `protocol-http` → provides HTTP abstractions
1500
+ - `protocol-http1` → implements HTTP/1.1 protocol
1501
+ - `protocol-http2` → implements HTTP/2 protocol
1502
+ - `async-http` → provides client/server using the protocols
1503
+
1504
+ Similarly:
1505
+ - `protocol-grpc` → provides gRPC abstractions (this gem)
1506
+ - `async-grpc` → provides client/server implementations (separate gem)
1507
+
1508
+ ### Why Build on Protocol::HTTP?
1509
+
1510
+ - **Reuse**: gRPC runs over HTTP/2; leverage existing HTTP/2 implementations
1511
+ - **Separation**: Keep gRPC protocol logic independent from transport concerns
1512
+ - **Compatibility**: Works with any Protocol::HTTP-compatible implementation
1513
+ - **Flexibility**: Users can choose their HTTP/2 client/server
1514
+
1515
+ ### Message Framing
1516
+
1517
+ gRPC uses a 5-byte prefix for each message:
1518
+ - 1 byte: Compression flag (0 = uncompressed, 1 = compressed)
1519
+ - 4 bytes: Message length (big-endian uint32)
1520
+
1521
+ This is handled by `Protocol::GRPC::Body::Readable` and `Writable`, which wrap `Protocol::HTTP::Body` classes.
1522
+
1523
+ ### Protobuf Interface
1524
+
1525
+ The gem defines a simple interface for protobuf messages:
1526
+ - Class method: `.decode(binary)` - decode binary data to message
1527
+ - Instance method: `#to_proto` or `#encode` - encode message to binary
1528
+
1529
+ Google's `protobuf` gem already provides these methods, so generated classes work without modification. This keeps the protocol layer decoupled from any specific protobuf implementation.
1530
+
1531
+ ### Status Codes and Trailers
1532
+
1533
+ gRPC uses its own status codes (0-16), which can be transmitted in two ways:
1534
+
1535
+ **1. Trailers-Only Response** (status in initial headers):
1536
+ - Used for immediate errors or responses without a body
1537
+ - `grpc-status` is sent as an initial header
1538
+ - `grpc-message` is sent as an initial header (if present)
1539
+ - HTTP status is still 200 (or 4xx/5xx in some cases)
1540
+ - No response body is sent
1541
+
1542
+ **2. Normal Response** (status in trailers):
1543
+ - Used for successful responses with data
1544
+ - Response body contains length-prefixed messages
1545
+ - `grpc-status` is sent as a trailer (after the body)
1546
+ - `grpc-message` is sent as a trailer if there's an error
1547
+
1548
+ **Understanding Protocol::HTTP::Headers Trailers:**
1549
+
1550
+ `Protocol::HTTP::Headers` has a `@tail` index that marks where trailers begin in the internal `@fields` array. When you call `headers.trailer!()`, it marks the current position as the start of trailers. Any headers added after that point are trailers.
1551
+
1552
+ From the user's perspective:
1553
+ - Access: `headers["grpc-status"]` works regardless of whether it's a header or trailer
1554
+ - The `@tail` marker is internal bookkeeping
1555
+ - Each header type has a `.trailer?` class method that determines if it's allowed in trailers
1556
+ - By default, most HTTP headers are NOT allowed in trailers
1557
+
1558
+ **gRPC Header Policy:**
1559
+
1560
+ Protocol::GRPC defines `HEADER_POLICY` that extends `Protocol::HTTP::Headers::POLICY`:
1561
+ - `grpc-status` → allowed BOTH as initial header AND as trailer
1562
+ - `grpc-message` → allowed BOTH as initial header AND as trailer
1563
+ - Custom metadata → can be sent as headers or trailers
1564
+
1565
+ The policy uses special header classes that return `.trailer? = true`, allowing them to appear in trailers. They can also appear as regular headers without calling `trailer!()`.
1566
+
1567
+ This policy must be passed when creating headers: `Protocol::HTTP::Headers.new([], nil, policy: Protocol::GRPC::HEADER_POLICY)`
1568
+
1569
+ ### Metadata
1570
+
1571
+ Custom metadata is transmitted as HTTP headers:
1572
+ - Regular metadata: plain headers (e.g., `authorization`)
1573
+ - Binary metadata: headers ending in `-bin`, base64 encoded
1574
+ - Reserved headers: `grpc-*`, `content-type`, `te`
1575
+
1576
+ ### Streaming Models
1577
+
1578
+ All four RPC patterns are supported through the protocol primitives:
1579
+ 1. **Unary**: Single message written and read
1580
+ 2. **Server Streaming**: Single write, multiple reads
1581
+ 3. **Client Streaming**: Multiple writes, single read
1582
+ 4. **Bidirectional**: Multiple writes and reads
1583
+
1584
+ These map naturally to `Protocol::HTTP::Body::Writable` and `Readable`.
1585
+
1586
+ ## Missing/Future Features
1587
+
1588
+ ### Protocol-Level Features (Phase 3+)
1589
+
1590
+ 1. **Compression Negotiation** (Compression already designed in Phase 2)
1591
+ - `grpc-accept-encoding` header parsing (advertise supported encodings)
1592
+ - Algorithm selection logic
1593
+ - Fallback to identity encoding
1594
+
1595
+ 2. **Service Descriptor Protocol**
1596
+ - Parse `.proto` files into descriptor objects
1597
+ - Used by reflection API
1598
+ - Service/method metadata
1599
+
1600
+ 3. **Health Check Protocol**
1601
+ - Standard health check message types
1602
+ - Health status enum (SERVING, NOT_SERVING, UNKNOWN)
1603
+ - Per-service health status
1604
+
1605
+ 4. **Reflection Protocol Messages**
1606
+ - ServerReflectionRequest/Response messages
1607
+ - FileDescriptorProto support
1608
+ - Service/method listing
1609
+
1610
+ 5. **Well-Known Error Details**
1611
+ - Standard error detail messages
1612
+ - `google.rpc.Status` with details
1613
+ - `google.rpc.ErrorInfo`, `google.rpc.RetryInfo`, etc.
1614
+
1615
+ ### Protocol Helpers (Should Add)
1616
+
1617
+ 6. **Binary Message Support** (✅ Designed)
1618
+ - `Body::Readable` and `Writable` work with raw binary when `message_class: nil`
1619
+ - No message_class = no decoding, just return binary
1620
+ - Needed for channel adapters and gRPC-web
1621
+
1622
+ 7. **Context/Call Metadata**
1623
+ - `Protocol::GRPC::Call` - represents single RPC (✅ Added)
1624
+ - Deadline tracking (✅ Added)
1625
+ - Cancellation signals (✅ Added)
1626
+ - Peer information (✅ Added)
1627
+
1628
+ 8. **Service Config**
1629
+ - Retry policy configuration
1630
+ - Timeout configuration
1631
+ - Load balancing hints
1632
+ - Parse from JSON
1633
+
1634
+ 9. **Name Resolution**
1635
+ - Parse gRPC URIs (dns:///host:port, etc.)
1636
+ - Service config discovery
1637
+
1638
+ ## Open Questions
1639
+
1640
+ 1. **Compression**: Protocol layer or implementation?
1641
+ - Protocol layer could provide `Body::Compressed` wrapper
1642
+ - Implementation handles negotiation
1643
+ - **Recommendation**: Protocol provides wrappers, implementation handles negotiation
1644
+
1645
+ 2. **Custom Metadata Policy**: Should all metadata be trailerable?
1646
+ - Current: Only `grpc-status` and `grpc-message` marked
1647
+ - gRPC allows most metadata in trailers
1648
+ - **Need to research**: Which headers MUST be in initial headers?
1649
+
1650
+ 3. **Binary Metadata**: Auto-encode/decode `-bin` headers?
1651
+ - Current design: Yes, automatically base64 encode/decode
1652
+ - Transparent for users
1653
+ - **Recommendation**: Keep automatic encoding
1654
+
1655
+ 4. **Message Class vs Decoder**: How to specify decoding?
1656
+ - Current: Pass message class, calls `.decode(data)`
1657
+ - Alternative: Pass decoder proc
1658
+ - **Recommendation**: Keep class-based, simple and clean
1659
+
1660
+ 5. **Error Hierarchy**: Specific classes or generic?
1661
+ - Current: Specific classes per status code
1662
+ - Makes rescue clauses cleaner
1663
+ - **Recommendation**: Keep specific classes
1664
+
1665
+ 6. **Service Discovery**: Should protocol-grpc include URI parsing?
1666
+ - gRPC URIs: `dns:///host:port`, `unix:///path`, etc.
1667
+ - Or leave to async-grpc?
1668
+ - **Recommendation**: Basic URI parsing in protocol-grpc
1669
+
1670
+ ## References
1671
+
1672
+ - [gRPC Protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md)
1673
+ - [Protocol::HTTP Design](https://socketry.github.io/protocol-http/guides/design-overview/)
1674
+ - [gRPC over HTTP/2](https://grpc.io/docs/what-is-grpc/core-concepts/)
1675
+