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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "protocol/http"
|
|
7
|
+
require "zlib"
|
|
8
|
+
|
|
9
|
+
module Protocol
|
|
10
|
+
module GRPC
|
|
11
|
+
# @namespace
|
|
12
|
+
module Body
|
|
13
|
+
# Represents a readable body for gRPC messages with length-prefixed framing.
|
|
14
|
+
# This is the standard readable body for gRPC - all gRPC responses use message framing.
|
|
15
|
+
class ReadableBody
|
|
16
|
+
# Initialize a new readable body for gRPC messages.
|
|
17
|
+
# @parameter body [Protocol::HTTP::Body::Readable] The underlying HTTP body
|
|
18
|
+
# @parameter message_class [Class | Nil] Protobuf message class with .decode method.
|
|
19
|
+
# If `nil`, returns raw binary data (useful for channel adapters)
|
|
20
|
+
# @parameter encoding [String | Nil] Compression encoding (from grpc-encoding header)
|
|
21
|
+
def initialize(body, message_class: nil, encoding: nil)
|
|
22
|
+
@body = body
|
|
23
|
+
@message_class = message_class
|
|
24
|
+
@encoding = encoding
|
|
25
|
+
@buffer = String.new.force_encoding(Encoding::BINARY)
|
|
26
|
+
@closed = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @attribute [Protocol::HTTP::Body::Readable] The underlying HTTP body.
|
|
30
|
+
attr_reader :body
|
|
31
|
+
|
|
32
|
+
# @attribute [String | Nil] The compression encoding.
|
|
33
|
+
attr_reader :encoding
|
|
34
|
+
|
|
35
|
+
# Close the input body.
|
|
36
|
+
# @parameter error [Exception | Nil] Optional error that caused the close
|
|
37
|
+
# @returns [Nil]
|
|
38
|
+
def close(error = nil)
|
|
39
|
+
@closed = true
|
|
40
|
+
|
|
41
|
+
if @body
|
|
42
|
+
@body.close(error)
|
|
43
|
+
@body = nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if the stream has been closed.
|
|
50
|
+
# @returns [Boolean] `true` if the stream is closed, `false` otherwise
|
|
51
|
+
def closed?
|
|
52
|
+
@closed or @body.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if there are any input chunks remaining.
|
|
56
|
+
# @returns [Boolean] `true` if the body is empty, `false` otherwise
|
|
57
|
+
def empty?
|
|
58
|
+
@body.nil?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Read the next gRPC message.
|
|
62
|
+
# @returns [Object | String | Nil] Decoded message, raw binary, or `Nil` if stream ended
|
|
63
|
+
def read
|
|
64
|
+
return nil if closed?
|
|
65
|
+
|
|
66
|
+
# Read 5-byte prefix: 1 byte compression flag + 4 bytes length
|
|
67
|
+
prefix = read_exactly(5)
|
|
68
|
+
return nil unless prefix
|
|
69
|
+
|
|
70
|
+
compressed = prefix[0].unpack1("C") == 1
|
|
71
|
+
length = prefix[1..4].unpack1("N")
|
|
72
|
+
|
|
73
|
+
# Read the message body:
|
|
74
|
+
data = read_exactly(length)
|
|
75
|
+
return nil unless data
|
|
76
|
+
|
|
77
|
+
# Decompress if needed:
|
|
78
|
+
data = decompress(data) if compressed
|
|
79
|
+
|
|
80
|
+
# Decode using message class if provided, otherwise return binary:
|
|
81
|
+
# This allows binary mode for channel adapters
|
|
82
|
+
if @message_class
|
|
83
|
+
# Use protobuf gem's decode method:
|
|
84
|
+
@message_class.decode(data)
|
|
85
|
+
else
|
|
86
|
+
data # Return raw binary
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Enumerate all messages until finished, then invoke {close}.
|
|
91
|
+
# @yields {|message| ...} The block to call with each message.
|
|
92
|
+
def each
|
|
93
|
+
return to_enum unless block_given?
|
|
94
|
+
|
|
95
|
+
error = nil
|
|
96
|
+
begin
|
|
97
|
+
while (message = read)
|
|
98
|
+
yield message
|
|
99
|
+
end
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
error = e
|
|
102
|
+
raise
|
|
103
|
+
ensure
|
|
104
|
+
close(error)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Read exactly n bytes from the underlying body.
|
|
111
|
+
# @parameter n [Integer] The number of bytes to read
|
|
112
|
+
# @returns [String | Nil] The data read, or `Nil` if the stream ended
|
|
113
|
+
def read_exactly(n)
|
|
114
|
+
# Fill buffer until we have enough data:
|
|
115
|
+
while @buffer.bytesize < n
|
|
116
|
+
return nil if closed?
|
|
117
|
+
|
|
118
|
+
# Read chunk from underlying body:
|
|
119
|
+
chunk = @body.read
|
|
120
|
+
|
|
121
|
+
if chunk.nil?
|
|
122
|
+
# End of stream:
|
|
123
|
+
if @body && !@closed
|
|
124
|
+
@body.close
|
|
125
|
+
@closed = true
|
|
126
|
+
end
|
|
127
|
+
return nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Append to buffer:
|
|
131
|
+
@buffer << chunk.force_encoding(Encoding::BINARY)
|
|
132
|
+
|
|
133
|
+
# Check if body is empty and close if needed:
|
|
134
|
+
if @body.empty?
|
|
135
|
+
@body.close
|
|
136
|
+
@closed = true
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Extract the required data:
|
|
141
|
+
data = @buffer[0...n]
|
|
142
|
+
@buffer = @buffer[n..]
|
|
143
|
+
data
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Decompress data using the configured encoding.
|
|
147
|
+
# @parameter data [String] The compressed data
|
|
148
|
+
# @returns [String] The decompressed data
|
|
149
|
+
# @raises [Error] If decompression fails
|
|
150
|
+
def decompress(data)
|
|
151
|
+
case @encoding
|
|
152
|
+
when "gzip"
|
|
153
|
+
Zlib::Gunzip.new.inflate(data)
|
|
154
|
+
when "deflate"
|
|
155
|
+
Zlib::Inflate.inflate(data)
|
|
156
|
+
else
|
|
157
|
+
data
|
|
158
|
+
end
|
|
159
|
+
rescue StandardError => error
|
|
160
|
+
raise Error.new(Status::INTERNAL, "Failed to decompress message: #{error.message}")
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "protocol/http"
|
|
7
|
+
require "protocol/http/body/writable"
|
|
8
|
+
require "zlib"
|
|
9
|
+
require "stringio"
|
|
10
|
+
|
|
11
|
+
module Protocol
|
|
12
|
+
module GRPC
|
|
13
|
+
# @namespace
|
|
14
|
+
module Body
|
|
15
|
+
# Represents a writable body for gRPC messages with length-prefixed framing.
|
|
16
|
+
# This is the standard writable body for gRPC - all gRPC requests use message framing.
|
|
17
|
+
class WritableBody < Protocol::HTTP::Body::Writable
|
|
18
|
+
# Initialize a new writable body for gRPC messages.
|
|
19
|
+
# @parameter encoding [String | Nil] Compression encoding (gzip, deflate, identity)
|
|
20
|
+
# @parameter level [Integer] Compression level if encoding is used
|
|
21
|
+
# @parameter message_class [Class | Nil] Expected message class for validation
|
|
22
|
+
def initialize(encoding: nil, level: Zlib::DEFAULT_COMPRESSION, message_class: nil, **options)
|
|
23
|
+
super(**options)
|
|
24
|
+
@encoding = encoding
|
|
25
|
+
@level = level
|
|
26
|
+
@message_class = message_class
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @attribute [String | Nil] The compression encoding.
|
|
30
|
+
attr_reader :encoding
|
|
31
|
+
|
|
32
|
+
# @attribute [Class | Nil] The expected message class for validation.
|
|
33
|
+
attr_reader :message_class
|
|
34
|
+
|
|
35
|
+
# Write a message with gRPC framing.
|
|
36
|
+
# @parameter message [Object, String] Protobuf message instance or raw binary data
|
|
37
|
+
# @parameter compressed [Boolean | Nil] Whether to compress this specific message. If `nil`, uses the encoding setting.
|
|
38
|
+
def write(message, compressed: nil)
|
|
39
|
+
# Validate message type if message_class is specified:
|
|
40
|
+
if @message_class && !message.is_a?(String)
|
|
41
|
+
unless message.is_a?(@message_class)
|
|
42
|
+
raise TypeError, "Expected #{@message_class}, got #{message.class}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Encode message to binary if it's not already a string:
|
|
47
|
+
# This supports both high-level (protobuf objects) and low-level (binary) usage
|
|
48
|
+
data = if message.is_a?(String)
|
|
49
|
+
message # Already binary, use as-is (for channel adapters)
|
|
50
|
+
elsif message.respond_to?(:to_proto)
|
|
51
|
+
# Use protobuf gem's to_proto method:
|
|
52
|
+
message.to_proto
|
|
53
|
+
elsif message.respond_to?(:encode)
|
|
54
|
+
# Use encode method:
|
|
55
|
+
message.encode
|
|
56
|
+
else
|
|
57
|
+
raise ArgumentError, "Message must respond to :to_proto or :encode"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Determine if we should compress this message:
|
|
61
|
+
# If compressed param is nil, use the encoding setting
|
|
62
|
+
should_compress = compressed.nil? ? (@encoding && @encoding != "identity") : compressed
|
|
63
|
+
|
|
64
|
+
# Compress if requested:
|
|
65
|
+
data = compress(data) if should_compress
|
|
66
|
+
|
|
67
|
+
# Build prefix: compression flag + length
|
|
68
|
+
compression_flag = should_compress ? 1 : 0
|
|
69
|
+
length = data.bytesize
|
|
70
|
+
prefix = [compression_flag].pack("C") + [length].pack("N")
|
|
71
|
+
|
|
72
|
+
# Write prefix + data to underlying body:
|
|
73
|
+
super(prefix + data) # Call Protocol::HTTP::Body::Writable#write
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
protected
|
|
77
|
+
|
|
78
|
+
# Compress data using the configured encoding.
|
|
79
|
+
# @parameter data [String] The data to compress
|
|
80
|
+
# @returns [String] The compressed data
|
|
81
|
+
# @raises [Error] If compression fails
|
|
82
|
+
def compress(data)
|
|
83
|
+
case @encoding
|
|
84
|
+
when "gzip"
|
|
85
|
+
io = StringIO.new
|
|
86
|
+
gz = Zlib::GzipWriter.new(io, @level)
|
|
87
|
+
gz.write(data)
|
|
88
|
+
gz.close
|
|
89
|
+
io.string
|
|
90
|
+
when "deflate"
|
|
91
|
+
Zlib::Deflate.deflate(data, @level)
|
|
92
|
+
else
|
|
93
|
+
data # No compression or identity
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError => error
|
|
96
|
+
raise Error.new(Status::INTERNAL, "Failed to compress message: #{error.message}")
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "async/deadline"
|
|
7
|
+
require_relative "methods"
|
|
8
|
+
|
|
9
|
+
module Protocol
|
|
10
|
+
module GRPC
|
|
11
|
+
# Represents context for a single RPC call.
|
|
12
|
+
class Call
|
|
13
|
+
# Initialize a new RPC call context.
|
|
14
|
+
# @parameter request [Protocol::HTTP::Request] The HTTP request
|
|
15
|
+
# @parameter deadline [Async::Deadline | Nil] Deadline for the call
|
|
16
|
+
def initialize(request, deadline: nil)
|
|
17
|
+
@request = request
|
|
18
|
+
@deadline = deadline
|
|
19
|
+
@cancelled = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @attribute [Protocol::HTTP::Request] The underlying HTTP request.
|
|
23
|
+
attr_reader :request
|
|
24
|
+
|
|
25
|
+
# @attribute [Async::Deadline | Nil] The deadline for this call.
|
|
26
|
+
attr_reader :deadline
|
|
27
|
+
|
|
28
|
+
# Extract metadata from request headers.
|
|
29
|
+
# @returns [Hash] Custom metadata key-value pairs
|
|
30
|
+
def metadata
|
|
31
|
+
@metadata ||= Methods.extract_metadata(@request.headers)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if the deadline has expired.
|
|
35
|
+
# @returns [Boolean] `true` if the deadline has expired, `false` otherwise
|
|
36
|
+
def deadline_exceeded?
|
|
37
|
+
@deadline&.expired? || false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Get the time remaining until the deadline.
|
|
41
|
+
# @returns [Numeric | Nil] Seconds remaining, or `Nil` if no deadline is set
|
|
42
|
+
def time_remaining
|
|
43
|
+
@deadline&.remaining
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Mark this call as cancelled.
|
|
47
|
+
def cancel!
|
|
48
|
+
@cancelled = true
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if the call was cancelled.
|
|
52
|
+
# @returns [Boolean] `true` if the call was cancelled, `false` otherwise
|
|
53
|
+
def cancelled?
|
|
54
|
+
@cancelled
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get peer information (client address).
|
|
58
|
+
# @returns [String | Nil] The peer address as a string, or `Nil` if not available
|
|
59
|
+
def peer
|
|
60
|
+
@request.peer&.to_s
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Protocol
|
|
7
|
+
module GRPC
|
|
8
|
+
# Base exception class for gRPC errors
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
attr_reader :status_code, :details, :metadata
|
|
11
|
+
|
|
12
|
+
# @parameter status_code [Integer] gRPC status code
|
|
13
|
+
# @parameter message [String | Nil] Error message
|
|
14
|
+
# @parameter details [Object | Nil] Error details
|
|
15
|
+
# @parameter metadata [Hash] Custom metadata
|
|
16
|
+
def initialize(status_code, message = nil, details: nil, metadata: {})
|
|
17
|
+
@status_code = status_code
|
|
18
|
+
@details = details
|
|
19
|
+
@metadata = metadata
|
|
20
|
+
super(message || Status::DESCRIPTIONS[status_code])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Map status code to error class
|
|
24
|
+
# @parameter status_code [Integer] gRPC status code
|
|
25
|
+
# @returns [Class] Error class for the status code
|
|
26
|
+
def self.error_class_for_status(status_code)
|
|
27
|
+
case status_code
|
|
28
|
+
when Status::CANCELLED then Cancelled
|
|
29
|
+
when Status::INVALID_ARGUMENT then InvalidArgument
|
|
30
|
+
when Status::DEADLINE_EXCEEDED then DeadlineExceeded
|
|
31
|
+
when Status::NOT_FOUND then NotFound
|
|
32
|
+
when Status::INTERNAL then Internal
|
|
33
|
+
when Status::UNAVAILABLE then Unavailable
|
|
34
|
+
when Status::UNAUTHENTICATED then Unauthenticated
|
|
35
|
+
else
|
|
36
|
+
Error
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Create an appropriate error instance for the given status code
|
|
41
|
+
# @parameter status_code [Integer] gRPC status code
|
|
42
|
+
# @parameter message [String | Nil] Error message
|
|
43
|
+
# @parameter metadata [Hash] Custom metadata
|
|
44
|
+
# @returns [Error] An instance of the appropriate error class
|
|
45
|
+
def self.for(status_code, message = nil, metadata: {})
|
|
46
|
+
error_class = error_class_for_status(status_code)
|
|
47
|
+
|
|
48
|
+
if error_class == Error
|
|
49
|
+
error_class.new(status_code, message, metadata: metadata)
|
|
50
|
+
else
|
|
51
|
+
error_class.new(message, metadata: metadata)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Represents a cancelled gRPC operation.
|
|
57
|
+
class Cancelled < Error
|
|
58
|
+
# Initialize a cancelled error.
|
|
59
|
+
# @parameter message [String | Nil] Optional error message
|
|
60
|
+
# @option options [Object | Nil] :details Optional error details
|
|
61
|
+
# @option options [Hash] :metadata Custom metadata
|
|
62
|
+
def initialize(message = nil, **options)
|
|
63
|
+
super(Status::CANCELLED, message, **options)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Represents an invalid argument error.
|
|
68
|
+
class InvalidArgument < Error
|
|
69
|
+
# Initialize an invalid argument error.
|
|
70
|
+
# @parameter message [String | Nil] Optional error message
|
|
71
|
+
# @option options [Object | Nil] :details Optional error details
|
|
72
|
+
# @option options [Hash] :metadata Custom metadata
|
|
73
|
+
def initialize(message = nil, **options)
|
|
74
|
+
super(Status::INVALID_ARGUMENT, message, **options)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Represents a deadline exceeded error.
|
|
79
|
+
class DeadlineExceeded < Error
|
|
80
|
+
# Initialize a deadline exceeded error.
|
|
81
|
+
# @parameter message [String | Nil] Optional error message
|
|
82
|
+
# @option options [Object | Nil] :details Optional error details
|
|
83
|
+
# @option options [Hash] :metadata Custom metadata
|
|
84
|
+
def initialize(message = nil, **options)
|
|
85
|
+
super(Status::DEADLINE_EXCEEDED, message, **options)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Represents a not found error.
|
|
90
|
+
class NotFound < Error
|
|
91
|
+
# Initialize a not found error.
|
|
92
|
+
# @parameter message [String | Nil] Optional error message
|
|
93
|
+
# @option options [Object | Nil] :details Optional error details
|
|
94
|
+
# @option options [Hash] :metadata Custom metadata
|
|
95
|
+
def initialize(message = nil, **options)
|
|
96
|
+
super(Status::NOT_FOUND, message, **options)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Represents an internal server error.
|
|
101
|
+
class Internal < Error
|
|
102
|
+
# Initialize an internal server error.
|
|
103
|
+
# @parameter message [String | Nil] Optional error message
|
|
104
|
+
# @option options [Object | Nil] :details Optional error details
|
|
105
|
+
# @option options [Hash] :metadata Custom metadata
|
|
106
|
+
def initialize(message = nil, **options)
|
|
107
|
+
super(Status::INTERNAL, message, **options)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Represents an unavailable service error.
|
|
112
|
+
class Unavailable < Error
|
|
113
|
+
# Initialize an unavailable service error.
|
|
114
|
+
# @parameter message [String | Nil] Optional error message
|
|
115
|
+
# @option options [Object | Nil] :details Optional error details
|
|
116
|
+
# @option options [Hash] :metadata Custom metadata
|
|
117
|
+
def initialize(message = nil, **options)
|
|
118
|
+
super(Status::UNAVAILABLE, message, **options)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Represents an unauthenticated error.
|
|
123
|
+
class Unauthenticated < Error
|
|
124
|
+
# Initialize an unauthenticated error.
|
|
125
|
+
# @parameter message [String | Nil] Optional error message
|
|
126
|
+
# @option options [Object | Nil] :details Optional error details
|
|
127
|
+
# @option options [Hash] :metadata Custom metadata
|
|
128
|
+
def initialize(message = nil, **options)
|
|
129
|
+
super(Status::UNAUTHENTICATED, message, **options)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require "protocol/http"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
require_relative "status"
|
|
10
|
+
|
|
11
|
+
module Protocol
|
|
12
|
+
module GRPC
|
|
13
|
+
# @namespace
|
|
14
|
+
module Header
|
|
15
|
+
# The `grpc-status` header represents the gRPC status code.
|
|
16
|
+
#
|
|
17
|
+
# The `grpc-status` header contains a numeric status code (0-16) indicating the result of the RPC call.
|
|
18
|
+
# Status code 0 indicates success (OK), while other codes indicate various error conditions.
|
|
19
|
+
# This header can appear both as an initial header (for trailers-only responses) and as a trailer.
|
|
20
|
+
class Status
|
|
21
|
+
# Initialize the status header with the given value.
|
|
22
|
+
#
|
|
23
|
+
# @parameter value [String, Integer] The status code as a string or integer.
|
|
24
|
+
def initialize(value)
|
|
25
|
+
@value = value.is_a?(String) ? value.to_i : value.to_i
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get the status code as an integer.
|
|
29
|
+
#
|
|
30
|
+
# @returns [Integer] The status code.
|
|
31
|
+
def to_i
|
|
32
|
+
@value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Serialize the status code to a string.
|
|
36
|
+
#
|
|
37
|
+
# @returns [String] The status code as a string.
|
|
38
|
+
def to_s
|
|
39
|
+
@value.to_s
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Merge another status value (takes the new value, as status should only appear once)
|
|
43
|
+
# @parameter value [String, Integer] The new status code
|
|
44
|
+
def <<(value)
|
|
45
|
+
@value = value.is_a?(String) ? value.to_i : value.to_i
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Whether this header is acceptable in HTTP trailers.
|
|
50
|
+
# The `grpc-status` header can appear in trailers as per the gRPC specification.
|
|
51
|
+
# @returns [Boolean] `true`, as grpc-status can appear in trailers.
|
|
52
|
+
def self.trailer?
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The `grpc-message` header represents the gRPC status message.
|
|
58
|
+
#
|
|
59
|
+
# The `grpc-message` header contains a human-readable error message, URL-encoded according to RFC 3986.
|
|
60
|
+
# This header is optional and typically only present when there's an error (non-zero status code).
|
|
61
|
+
# This header can appear both as an initial header (for trailers-only responses) and as a trailer.
|
|
62
|
+
class Message < String
|
|
63
|
+
# Initialize the message header with the given value.
|
|
64
|
+
#
|
|
65
|
+
# @parameter value [String] The message value (will be URL-encoded if not already encoded).
|
|
66
|
+
def initialize(value)
|
|
67
|
+
super(value.to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Decode the URL-encoded message.
|
|
71
|
+
#
|
|
72
|
+
# @returns [String] The decoded message.
|
|
73
|
+
def decode
|
|
74
|
+
URI.decode_www_form_component(self)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Encode the message for use in headers.
|
|
78
|
+
#
|
|
79
|
+
# @parameter message [String] The message to encode.
|
|
80
|
+
# @returns [String] The URL-encoded message.
|
|
81
|
+
def self.encode(message)
|
|
82
|
+
URI.encode_www_form_component(message).gsub("+", "%20")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Merge another message value (takes the new value, as message should only appear once)
|
|
86
|
+
# @parameter value [String] The new message value
|
|
87
|
+
def <<(value)
|
|
88
|
+
replace(value.to_s)
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Whether this header is acceptable in HTTP trailers.
|
|
93
|
+
# The `grpc-message` header can appear in trailers as per the gRPC specification.
|
|
94
|
+
# @returns [Boolean] `true`, as grpc-message can appear in trailers.
|
|
95
|
+
def self.trailer?
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Base class for custom gRPC metadata (allowed in trailers).
|
|
101
|
+
class Metadata < Protocol::HTTP::Header::Split
|
|
102
|
+
# Whether this header is acceptable in HTTP trailers.
|
|
103
|
+
# The `grpc-metadata` header can appear in trailers as per the gRPC specification.
|
|
104
|
+
# @returns [Boolean] `true`, as grpc-metadata can appear in trailers.
|
|
105
|
+
def self.trailer?
|
|
106
|
+
true
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Custom header policy for gRPC.
|
|
112
|
+
# Extends Protocol::HTTP::Headers::POLICY with gRPC-specific headers.
|
|
113
|
+
HEADER_POLICY = Protocol::HTTP::Headers::POLICY.merge(
|
|
114
|
+
"grpc-status" => Header::Status,
|
|
115
|
+
"grpc-message" => Header::Message
|
|
116
|
+
# By default, all other headers follow standard HTTP policy, but gRPC allows most metadata to be sent as trailers.
|
|
117
|
+
).freeze
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
module Protocol
|
|
7
|
+
module GRPC
|
|
8
|
+
# @namespace
|
|
9
|
+
module HealthCheck
|
|
10
|
+
# Health check status constants
|
|
11
|
+
module ServingStatus
|
|
12
|
+
UNKNOWN = 0
|
|
13
|
+
SERVING = 1
|
|
14
|
+
NOT_SERVING = 2
|
|
15
|
+
SERVICE_UNKNOWN = 3
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the MIT License.
|
|
4
|
+
# Copyright, 2025, by Samuel Williams.
|
|
5
|
+
|
|
6
|
+
require_relative "methods"
|
|
7
|
+
|
|
8
|
+
module Protocol
|
|
9
|
+
module GRPC
|
|
10
|
+
# RPC method definition
|
|
11
|
+
RPC = Struct.new(:request_class, :response_class, :streaming, :method, keyword_init: true) do
|
|
12
|
+
def initialize(request_class:, response_class:, streaming: :unary, method: nil)
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Represents an interface definition for gRPC methods.
|
|
18
|
+
# Can be used by both client stubs and server implementations.
|
|
19
|
+
class Interface
|
|
20
|
+
# Hook called when a subclass is created.
|
|
21
|
+
# Initializes the RPC hash for the subclass.
|
|
22
|
+
# @parameter subclass [Class] The subclass being created
|
|
23
|
+
def self.inherited(subclass)
|
|
24
|
+
super
|
|
25
|
+
|
|
26
|
+
subclass.instance_variable_set(:@rpcs, {})
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Define an RPC method.
|
|
30
|
+
# @parameter name [Symbol] Method name in PascalCase (e.g., :SayHello, matching .proto file)
|
|
31
|
+
# @parameter request_class [Class] Request message class
|
|
32
|
+
# @parameter response_class [Class] Response message class
|
|
33
|
+
# @parameter streaming [Symbol] Streaming type (:unary, :server_streaming, :client_streaming, :bidirectional)
|
|
34
|
+
# @parameter method [Symbol | Nil] Optional explicit Ruby method name (snake_case). If not provided, automatically converts PascalCase to snake_case.
|
|
35
|
+
def self.rpc(name, **options)
|
|
36
|
+
@rpcs[name] = RPC.new(**options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Look up RPC definition for a method.
|
|
40
|
+
# Looks up the inheritance chain to find the RPC definition.
|
|
41
|
+
# @parameter name [Symbol] Method name.
|
|
42
|
+
# @returns [Protocol::GRPC::RPC | Nil] RPC definition or `Nil` if not found.
|
|
43
|
+
def self.lookup_rpc(name)
|
|
44
|
+
klass = self
|
|
45
|
+
while klass && klass != Interface
|
|
46
|
+
if klass.instance_variable_defined?(:@rpcs)
|
|
47
|
+
rpc = klass.instance_variable_get(:@rpcs)[name]
|
|
48
|
+
return rpc if rpc
|
|
49
|
+
end
|
|
50
|
+
klass = klass.superclass
|
|
51
|
+
end
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get all RPC definitions from this class and all parent classes.
|
|
56
|
+
# @returns [Hash] All RPC definitions merged from inheritance chain
|
|
57
|
+
def self.rpcs
|
|
58
|
+
all_rpcs = {}
|
|
59
|
+
klass = self
|
|
60
|
+
|
|
61
|
+
# Walk up the inheritance chain:
|
|
62
|
+
while klass && klass != Interface
|
|
63
|
+
if klass.instance_variable_defined?(:@rpcs)
|
|
64
|
+
all_rpcs.merge!(klass.instance_variable_get(:@rpcs))
|
|
65
|
+
end
|
|
66
|
+
klass = klass.superclass
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
all_rpcs
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @attribute [String] The service name (e.g., "hello.Greeter").
|
|
73
|
+
attr :name
|
|
74
|
+
|
|
75
|
+
# Initialize a new interface instance.
|
|
76
|
+
# @parameter name [String] Service name
|
|
77
|
+
def initialize(name)
|
|
78
|
+
@name = name
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Build gRPC path for a method.
|
|
82
|
+
# @parameter method_name [String, Symbol] Method name in PascalCase (e.g., :SayHello)
|
|
83
|
+
# @returns [String] gRPC path with PascalCase method name
|
|
84
|
+
def path(method_name)
|
|
85
|
+
Methods.build_path(@name, method_name.to_s)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|