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.
- checksums.yaml +7 -0
- data/context/getting-started.md +132 -0
- data/context/index.yaml +12 -0
- data/design.md +1675 -0
- data/lib/protocol/grpc/body/readable_body.rb +165 -0
- data/lib/protocol/grpc/body/writable_body.rb +101 -0
- data/lib/protocol/grpc/call.rb +64 -0
- data/lib/protocol/grpc/error.rb +133 -0
- data/lib/protocol/grpc/header.rb +119 -0
- data/lib/protocol/grpc/health_check.rb +19 -0
- data/lib/protocol/grpc/interface.rb +89 -0
- data/lib/protocol/grpc/metadata.rb +117 -0
- data/lib/protocol/grpc/methods.rb +113 -0
- data/lib/protocol/grpc/middleware.rb +71 -0
- data/lib/protocol/grpc/status.rb +50 -0
- data/lib/protocol/grpc/version.rb +12 -0
- data/lib/protocol/grpc.rb +35 -0
- data/license.md +21 -0
- data/readme.md +57 -0
- data/releases.md +5 -0
- metadata +114 -0
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
|
+
|