Protocol abstractions for gRPC, built on top of protocol-http.
gRPC is an RPC framework that runs over HTTP/2. It uses Protocol Buffers for serialization and supports four types of RPC patterns:
- Unary RPC: Single request, single response
- Client Streaming: Stream of requests, single response
- Server Streaming: Single request, stream of responses
- Bidirectional Streaming: Stream of requests, stream of responses
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).
The protocol layer describes:
- How to frame gRPC messages (length-prefixed format)
- How to encode/decode gRPC metadata and trailers
- Status codes and error handling
- Request/response structure
It does NOT include:
- Actual network I/O
- Connection management
- Client or server implementations
- Concurrency primitives
The protocol layer provides these core abstractions:
- Message Interface -
Protocol::GRPC::MessageandMessageHelpers - Path Handling -
Protocol::GRPC::Methods(build/parse paths, headers, timeouts) - Metadata -
Protocol::GRPC::Metadata(extract status, build trailers) - Body Framing -
Protocol::GRPC::Body::ReadableandBody::Writable - Status Codes -
Protocol::GRPC::Statusconstants - Errors -
Protocol::GRPC::Errorhierarchy - Call Context -
Protocol::GRPC::Call(deadline tracking, metadata) - Server Middleware -
Protocol::GRPC::Middleware(handles gRPC requests) - Health Check -
Protocol::GRPC::HealthCheckprotocol - Code Generation -
Protocol::GRPC::Generator(parse .proto, generate stubs)
Key Design Principles:
- gRPC bodies are always message-framed (never raw bytes)
- Use
Body::ReadablenotBody::Message(it's the standard for gRPC) - Use
Body::WritablenotBody::MessageWriter(it's the standard for gRPC) - Compression is built-in via
encoding:parameter (not separate wrapper) - Binary mode: pass
message_class: nilto work with raw bytes
Interface for protobuf messages. Any protobuf implementation can conform to this:
module Protocol
module GRPC
# Defines the interface that protobuf messages should implement
# to work with Protocol::GRPC body encoding/decoding.
#
# Google's protobuf-ruby gem already provides these methods on generated classes:
# - MyMessage.decode(binary_string)
# - message_instance.to_proto
module Message
# Decode a binary protobuf string into a message instance
# @parameter data [String] Binary protobuf data
# @returns [Object] Decoded message instance
def decode(data)
raise NotImplementedError
end
# Encode a message instance to binary protobuf format
# @returns [String] Binary protobuf data
def encode
raise NotImplementedError
end
# Alias for encode to match google-protobuf convention
alias to_proto encode
end
# Helper methods for working with protobuf messages
module MessageHelpers
# Check if a class/object supports the protobuf message interface
# @parameter klass [Class, Object] The class or instance to check
# @returns [Boolean]
def self.protobuf?(klass)
klass.respond_to?(:decode) && (klass.respond_to?(:encode) || klass.respond_to?(:to_proto))
end
# Encode a message using the appropriate method
# @parameter message [Object] Message instance
# @returns [String] Binary protobuf data
def self.encode(message)
if message.respond_to?(:to_proto)
message.to_proto
elsif message.respond_to?(:encode)
message.encode
else
raise ArgumentError, "Message must respond to :to_proto or :encode"
end
end
# Decode binary data using the message class
# @parameter klass [Class] Message class with decode method
# @parameter data [String] Binary protobuf data
# @returns [Object] Decoded message instance
def self.decode(klass, data)
unless klass.respond_to?(:decode)
raise ArgumentError, "Message class must respond to :decode"
end
klass.decode(data)
end
end
end
endPath 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.
Helper module for building gRPC-compatible HTTP requests:
module Protocol
module GRPC
module Methods
# Build gRPC path from service and method
# @parameter service [String] e.g., "my_service.Greeter"
# @parameter method [String] e.g., "SayHello"
# @returns [String] e.g., "/my_service.Greeter/SayHello"
def self.build_path(service, method)
"/#{service}/#{method}"
end
# Parse service and method from gRPC path
# @parameter path [String] e.g., "/my_service.Greeter/SayHello"
# @returns [Tuple(String, String)] of service and method.
def self.parse_path(path)
parts = path.split("/")
[parts[1], parts[2]]
end
# Build gRPC request headers
# @parameter metadata [Hash] Custom metadata key-value pairs
# @parameter timeout [Numeric] Optional timeout in seconds
# @returns [Protocol::HTTP::Headers]
def self.build_headers(metadata: {}, timeout: nil, content_type: "application/grpc+proto")
headers = Protocol::HTTP::Headers.new
headers["content-type"] = content_type
headers["te"] = "trailers"
headers["grpc-timeout"] = format_timeout(timeout) if timeout
metadata.each do |key, value|
# Binary headers end with -bin and are base64 encoded
if key.end_with?("-bin")
headers[key] = Base64.strict_encode64(value)
else
headers[key] = value.to_s
end
end
headers
end
# Extract metadata from gRPC headers
# @parameter headers [Protocol::HTTP::Headers]
# @returns [Hash] Metadata key-value pairs
def self.extract_metadata(headers)
metadata = {}
headers.each do |key, value|
# Skip reserved headers
next if key.start_with?("grpc-") || key == "content-type" || key == "te"
# Decode binary headers
if key.end_with?("-bin")
metadata[key] = Base64.strict_decode64(value)
else
metadata[key] = value
end
end
metadata
end
# Format timeout for grpc-timeout header
# @parameter timeout [Numeric] Timeout in seconds
# @returns [String] e.g., "1000m" for 1 second
def self.format_timeout(timeout)
# gRPC timeout format: value + unit (H=hours, M=minutes, S=seconds, m=milliseconds, u=microseconds, n=nanoseconds)
if timeout >= 3600
"#{(timeout / 3600).to_i}H"
elsif timeout >= 60
"#{(timeout / 60).to_i}M"
elsif timeout >= 1
"#{timeout.to_i}S"
elsif timeout >= 0.001
"#{(timeout * 1000).to_i}m"
elsif timeout >= 0.000001
"#{(timeout * 1_000_000).to_i}u"
else
"#{(timeout * 1_000_000_000).to_i}n"
end
end
# Parse grpc-timeout header value
# @parameter value [String] e.g., "1000m"
# @returns [Numeric] Timeout in seconds
def self.parse_timeout(value)
return nil unless value
amount = value[0...-1].to_i
unit = value[-1]
case unit
when "H" then amount * 3600
when "M" then amount * 60
when "S" then amount
when "m" then amount / 1000.0
when "u" then amount / 1_000_000.0
when "n" then amount / 1_000_000_000.0
else nil
end
end
end
end
endgRPC-specific header policy and metadata handling:
module Protocol
module GRPC
module Header
# Header class for grpc-status (allowed in trailers)
class Status < Protocol::HTTP::Header::Split
def self.trailer?
true
end
end
# Header class for grpc-message (allowed in trailers)
class Message < Protocol::HTTP::Header::Split
def self.trailer?
true
end
end
# Base class for custom gRPC metadata (allowed in trailers)
class Metadata < Protocol::HTTP::Header::Split
def self.trailer?
true
end
end
end
# Custom header policy for gRPC
# Extends Protocol::HTTP::Headers::POLICY with gRPC-specific headers
HEADER_POLICY = Protocol::HTTP::Headers::POLICY.merge(
# Request headers:
"grpc-timeout" => Header::Timeout,
"grpc-encoding" => Header::Encoding,
# Response headers:
"grpc-status" => Header::Status,
"grpc-message" => Header::Message,
# By default, all other headers follow standard HTTP policy
# But gRPC allows most metadata to be sent as trailers
).freeze
module Metadata
# Extract gRPC status from headers
# Note: In Protocol::HTTP::Headers, trailers are merged into the headers
# so users just access headers["grpc-status"] regardless of whether it
# was sent as an initial header or trailer.
#
# @parameter headers [Protocol::HTTP::Headers]
# @returns [Integer] Status code (0-16)
def self.extract_status(headers)
status = headers["grpc-status"]
status ? status.to_i : Status::UNKNOWN
end
# Extract gRPC status message from headers
# @parameter headers [Protocol::HTTP::Headers]
# @returns [String | Nil] Status message
def self.extract_message(headers)
message = headers["grpc-message"]
message ? URI.decode_www_form_component(message) : nil
end
# Add gRPC status, message, and optional backtrace to headers.
# Whether these become headers or trailers is controlled by the protocol layer.
# @parameter headers [Protocol::HTTP::Headers]
# @parameter status [Integer] gRPC status code
# @parameter message [String | Nil] Optional status message
# @parameter error [Exception | Nil] Optional error object (used to extract backtrace)
def self.add_status!(headers, status: Status::OK, message: nil, error: nil)
headers["grpc-status"] = Header::Status.new(status)
headers["grpc-message"] = Header::Message.new(Header::Message.encode(message)) if message
# Add backtrace from error if available
if error && error.backtrace && !error.backtrace.empty?
headers["backtrace"] = error.backtrace
end
end
end
end
endReads length-prefixed gRPC messages.
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.
module Protocol
module GRPC
module Body
# Reads length-prefixed gRPC messages from an HTTP body
# This is the standard readable body for gRPC - all gRPC responses use message framing
class Readable < Protocol::HTTP::Body::Wrapper
# @parameter body [Protocol::HTTP::Body::Readable] The underlying HTTP body
# @parameter message_class [Class, nil] Protobuf message class with .decode method
# If nil, returns raw binary data (useful for channel adapters)
# @parameter encoding [String | Nil] Compression encoding (from grpc-encoding header)
def initialize(body, message_class: nil, encoding: nil)
super(body)
@message_class = message_class
@encoding = encoding
@buffer = String.new.force_encoding(Encoding::BINARY)
end
# Override read to return decoded messages instead of raw chunks
# This makes the wrapper transparent - users call .read and get messages
# @returns [Object | String | Nil] Decoded message, raw binary, or nil if stream ended
def read
# Read 5-byte prefix: 1 byte compression flag + 4 bytes length
prefix = read_exactly(5)
return nil unless prefix
compressed = prefix[0].unpack1("C") == 1
length = prefix[1..4].unpack1("N")
# Read the message body
data = read_exactly(length)
return nil unless data
# Decompress if needed
data = decompress(data) if compressed
# Decode using message class if provided, otherwise return binary
# This allows binary mode for channel adapters
if @message_class
MessageHelpers.decode(@message_class, data)
else
data # Return raw binary
end
end
# Standard Protocol::HTTP::Body::Readable#each now iterates messages
# No need for separate each_message method
# Inherited from Readable:
# def each
# return to_enum unless block_given?
#
# begin
# while message = self.read
# yield message
# end
# rescue => error
# raise
# ensure
# self.close(error)
# end
# end
private
def read_exactly(n)
while @buffer.bytesize < n
chunk = @body.read
return nil unless chunk
@buffer << chunk
end
data = @buffer[0...n]
@buffer = @buffer[n..-1]
data
end
def decompress(data)
# TODO: Implement gzip decompression
data
end
def close(error = nil)
@body&.close(error)
end
end
# Writes length-prefixed gRPC messages
# This is the standard writable body for gRPC - all gRPC requests use message framing
class Writable < Protocol::HTTP::Body::Writable
# @parameter encoding [String | Nil] Compression encoding (gzip, deflate, identity)
# @parameter level [Integer] Compression level if encoding is used
def initialize(encoding: nil, level: Zlib::DEFAULT_COMPRESSION, **options)
super(**options)
@encoding = encoding
@level = level
end
attr :encoding
# Write a message with gRPC framing
# @parameter message [Object, String] Protobuf message instance or raw binary data
# @parameter compressed [Boolean] Whether to compress this specific message
def write(message, compressed: nil)
# Encode message to binary if it's not already a string
# This supports both high-level (protobuf objects) and low-level (binary) usage
data = if message.is_a?(String)
message # Already binary, use as-is (for channel adapters)
else
MessageHelpers.encode(message) # Encode protobuf object
end
# Determine if we should compress this message
# If compressed param is nil, use the encoding setting
should_compress = compressed.nil? ? (@encoding && @encoding != "identity") : compressed
# Compress if requested
data = compress(data) if should_compress
# Build prefix: compression flag + length
compression_flag = should_compress ? 1 : 0
length = data.bytesize
prefix = [compression_flag].pack("C") + [length].pack("N")
# Write prefix + data to underlying body
super(prefix + data) # Call Protocol::HTTP::Body::Writable#write
end
protected
def compress(data)
case @encoding
when "gzip"
require "zlib"
io = StringIO.new
gz = Zlib::GzipWriter.new(io, @level)
gz.write(data)
gz.close
io.string
when "deflate"
require "zlib"
Zlib::Deflate.deflate(data, @level)
else
data # No compression or identity
end
end
end
end
end
endgRPC status codes (as constants):
module Protocol
module GRPC
module Status
OK = 0
CANCELLED = 1
UNKNOWN = 2
INVALID_ARGUMENT = 3
DEADLINE_EXCEEDED = 4
NOT_FOUND = 5
ALREADY_EXISTS = 6
PERMISSION_DENIED = 7
RESOURCE_EXHAUSTED = 8
FAILED_PRECONDITION = 9
ABORTED = 10
OUT_OF_RANGE = 11
UNIMPLEMENTED = 12
INTERNAL = 13
UNAVAILABLE = 14
DATA_LOSS = 15
UNAUTHENTICATED = 16
# Status code descriptions
DESCRIPTIONS = {
OK => "OK",
CANCELLED => "Cancelled",
UNKNOWN => "Unknown",
INVALID_ARGUMENT => "Invalid Argument",
DEADLINE_EXCEEDED => "Deadline Exceeded",
NOT_FOUND => "Not Found",
ALREADY_EXISTS => "Already Exists",
PERMISSION_DENIED => "Permission Denied",
RESOURCE_EXHAUSTED => "Resource Exhausted",
FAILED_PRECONDITION => "Failed Precondition",
ABORTED => "Aborted",
OUT_OF_RANGE => "Out of Range",
UNIMPLEMENTED => "Unimplemented",
INTERNAL => "Internal",
UNAVAILABLE => "Unavailable",
DATA_LOSS => "Data Loss",
UNAUTHENTICATED => "Unauthenticated"
}.freeze
end
end
endRepresents a single RPC call with metadata and deadline tracking:
module Protocol
module GRPC
# Represents context for a single RPC call
class Call
# @parameter request [Protocol::HTTP::Request] The HTTP request
# @parameter deadline [Time, nil] Absolute deadline for the call
def initialize(request, deadline: nil)
@request = request
@deadline = deadline
@cancelled = false
end
# @attribute [Protocol::HTTP::Request] The underlying HTTP request
attr :request
# @attribute [Time, nil] The deadline for this call
attr :deadline
# Extract metadata from request headers
# @returns [Hash] Custom metadata
def metadata
@metadata ||= Methods.extract_metadata(@request.headers)
end
# Check if the deadline has expired
# @returns [Boolean]
def deadline_exceeded?
@deadline && Time.now > @deadline
end
# Time remaining until deadline
# @returns [Numeric, nil] Seconds remaining, or nil if no deadline
def time_remaining
@deadline ? [@deadline - Time.now, 0].max : nil
end
# Mark this call as cancelled
def cancel!
@cancelled = true
end
# Check if call was cancelled
# @returns [Boolean]
def cancelled?
@cancelled
end
# Get peer information (client address)
# @returns [String | Nil]
def peer
@request.peer&.to_s
end
end
end
endException hierarchy for gRPC errors:
module Protocol
module GRPC
class Error < StandardError
attr_reader :status_code, :details, :metadata
def initialize(status_code, message = nil, details: nil, metadata: {})
@status_code = status_code
@details = details
@metadata = metadata
super(message || Status::DESCRIPTIONS[status_code])
end
end
# Specific error classes for common status codes
class Cancelled < Error
def initialize(message = nil, **options)
super(Status::CANCELLED, message, **options)
end
end
class InvalidArgument < Error
def initialize(message = nil, **options)
super(Status::INVALID_ARGUMENT, message, **options)
end
end
class DeadlineExceeded < Error
def initialize(message = nil, **options)
super(Status::DEADLINE_EXCEEDED, message, **options)
end
end
class NotFound < Error
def initialize(message = nil, **options)
super(Status::NOT_FOUND, message, **options)
end
end
class Internal < Error
def initialize(message = nil, **options)
super(Status::INTERNAL, message, **options)
end
end
class Unavailable < Error
def initialize(message = nil, **options)
super(Status::UNAVAILABLE, message, **options)
end
end
class Unauthenticated < Error
def initialize(message = nil, **options)
super(Status::UNAUTHENTICATED, message, **options)
end
end
end
endKey Difference from HTTP:
- HTTP: Entire body is compressed (
content-encoding: gzip) - gRPC: Each message is individually compressed (per-message compression flag)
grpc-encodingheader indicates the algorithm used- Each message's byte 0 (compression flag) indicates if that specific message is compressed
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.
# Compression is built-in, controlled by encoding parameter
body = Protocol::GRPC::Body::Writable.new(encoding: "gzip")
body.write(message) # Automatically compressed with prefix
# Reading automatically decompresses
encoding = response.headers["grpc-encoding"]
body = Protocol::GRPC::Body::Readable.new(
response.body,
message_class: MyReply,
encoding: encoding
)
message = body.read # Automatically decompressedThis is cleaner than Protocol::HTTP's separate Deflate/Inflate wrappers because:
- gRPC always has message framing (never raw bytes)
- Compression is per-message (part of the framing protocol)
- No need for separate wrapper classes
Server middleware for handling gRPC requests:
module Protocol
module GRPC
# Server middleware for handling gRPC requests
# Implements Protocol::HTTP::Middleware interface
# This is the protocol-level server - no async, just request/response handling
class Middleware < Protocol::HTTP::Middleware
# @parameter app [#call] The next middleware in the chain
# @parameter services [Hash] Map of service name => service handler
def initialize(app = nil, services: {})
super(app)
@services = services
end
# Register a service handler
# @parameter service_name [String] Full service name, e.g., "my_service.Greeter"
# @parameter handler [Object] Service implementation
def register(service_name, handler)
@services[service_name] = handler
end
# Handle incoming HTTP request
# @parameter request [Protocol::HTTP::Request]
# @returns [Protocol::HTTP::Response]
def call(request)
# Check if this is a gRPC request
unless grpc_request?(request)
# Not a gRPC request, pass to next middleware
return super
end
# Parse service and method from path
service_name, method_name = Methods.parse_path(request.path)
# Find handler
handler = @services[service_name]
unless handler
return make_response(Status::UNIMPLEMENTED, "Service not found: #{service_name}")
end
# Determine handler method and message classes
rpc_desc = handler.class.respond_to?(:rpc_descriptions) ? handler.class.rpc_descriptions[method_name] : nil
if rpc_desc
# Use generated RPC descriptor
handler_method = rpc_desc[:method]
request_class = rpc_desc[:request_class]
response_class = rpc_desc[:response_class]
else
# Fallback to simple method name
handler_method = method_name.underscore.to_sym
request_class = nil
response_class = nil
end
unless handler.respond_to?(handler_method)
return make_response(Status::UNIMPLEMENTED, "Method not found: #{method_name}")
end
# Handle the RPC
begin
handle_rpc(request, handler, handler_method, request_class, response_class)
rescue Error => error
make_response(error.status_code, error.message, error: error)
rescue => error
make_response(Status::INTERNAL, error.message, error: error)
end
end
protected
def grpc_request?(request)
content_type = request.headers["content-type"]
content_type&.start_with?("application/grpc")
end
# Override in subclass to add async handling
def handle_rpc(request, handler, method, request_class, response_class)
# Create input/output streams
encoding = request.headers["grpc-encoding"]
input = Body::Readable.new(request.body, message_class: request_class, encoding: encoding)
output = Body::Writable.new(encoding: encoding)
# Create call context
response_headers = Protocol::HTTP::Headers.new([], nil, policy: HEADER_POLICY)
response_headers["content-type"] = "application/grpc+proto"
response_headers["grpc-encoding"] = encoding if encoding
call = Call.new(request)
# Invoke handler
handler.send(method, input, output, call)
output.close_write unless output.closed?
# Mark trailers and add status
response_headers.trailer!
Metadata.add_status!(response_headers, status: Status::OK)
Protocol::HTTP::Response[200, response_headers, output]
end
protected
def make_response(status_code, message, error: nil)
headers = Protocol::HTTP::Headers.new([], nil, policy: HEADER_POLICY)
headers["content-type"] = "application/grpc+proto"
Metadata.add_status!(headers, status: status_code, message: message, error: error)
Protocol::HTTP::Response[200, headers, nil]
end
end
end
endStandard health checking protocol:
module Protocol
module GRPC
module HealthCheck
# Health check status constants
module ServingStatus
UNKNOWN = 0
SERVING = 1
NOT_SERVING = 2
SERVICE_UNKNOWN = 3
end
end
end
endrequire "protocol/grpc"
require "protocol/http"
# Create request body with protobuf messages
body = Protocol::GRPC::Body::Writable.new
body.write(MyService::HelloRequest.new(name: "World"))
body.close_write
# Build gRPC headers
headers = Protocol::GRPC::Methods.build_headers(
metadata: {"authorization" => "Bearer token123"},
timeout: 5.0
)
# Create HTTP request with gRPC path
path = Protocol::GRPC::Methods.build_path("my_service.Greeter", "SayHello")
request = Protocol::HTTP::Request[
"POST", path,
headers: headers,
body: body,
scheme: "https",
authority: "localhost:50051"
]
# Request is now ready to be sent via any HTTP/2 client
# (e.g., Async::HTTP::Client, or any other Protocol::HTTP-compatible client)require "protocol/grpc"
# Assume we got an HTTP response from somewhere
# http_response = client.call(request)
# Read protobuf messages from response body
message_body = Protocol::GRPC::Body::Readable.new(
http_response.body,
message_class: MyService::HelloReply
)
# Read single message (unary RPC)
reply = message_body.read
puts reply.message
# Or iterate over multiple messages (server streaming)
message_body.each do |reply|
puts reply.message
end
# Extract gRPC status from trailers
status = Protocol::GRPC::Metadata.extract_status(http_response.headers)
if status != Protocol::GRPC::Status::OK
message = Protocol::GRPC::Metadata.extract_message(http_response.headers)
raise Protocol::GRPC::Error.new(status, message)
endrequire "protocol/grpc"
# This would be inside a Rack/HTTP middleware/handler
def handle_grpc_request(http_request)
# Parse gRPC path
service, method = Protocol::GRPC::Methods.parse_path(http_request.path)
# Read input messages
input = Protocol::GRPC::Body::Readable.new(
http_request.body,
message_class: MyService::HelloRequest
)
request_message = input.read
# Process the request
reply = MyService::HelloReply.new(
message: "Hello, #{request_message.name}!"
)
# Create response body
output = Protocol::GRPC::Body::Writable.new
output.write(reply)
output.close_write
# Build response headers with gRPC policy
headers = Protocol::HTTP::Headers.new([], nil, policy: Protocol::GRPC::HEADER_POLICY)
headers["content-type"] = "application/grpc+proto"
# Mark that trailers will follow (after body)
headers.trailer!
# Add status as trailer - these will be sent after the response body
# Note: The user just adds them to headers; the @tail marker ensures
# they're recognized as trailers internally
Protocol::GRPC::Metadata.add_status!(headers, status: Protocol::GRPC::Status::OK)
Protocol::HTTP::Response[200, headers, output]
endrequire "protocol/grpc"
require_relative "my_service_pb" # Generated by protoc
# Google's protobuf gem generates classes that work automatically:
# - MyService::HelloRequest has .decode(binary) class method
# - message instances have #to_proto instance method
# Check if a class is compatible
Protocol::GRPC::MessageHelpers.protobuf?(MyService::HelloRequest) # => true
# Encode a message
request = MyService::HelloRequest.new(name: "World")
binary = Protocol::GRPC::MessageHelpers.encode(request)
# Decode a message
decoded = Protocol::GRPC::MessageHelpers.decode(MyService::HelloRequest, binary)
# These helpers allow the protocol layer to work with any protobuf
# implementation that provides .decode and #to_proto / #encode methodsrequire "protocol/grpc"
# Example 1: Normal Response (status in trailers)
# Create headers with gRPC policy (required for trailer support)
headers = Protocol::HTTP::Headers.new([], nil, policy: Protocol::GRPC::HEADER_POLICY)
# Add initial headers
headers["content-type"] = "application/grpc+proto"
headers["custom-metadata"] = "initial-value"
# Mark the boundary: everything added after this is a trailer
headers.trailer!
# Add trailers (sent after response body)
headers["grpc-status"] = "0"
headers["grpc-message"] = "OK"
headers["custom-trailer"] = "final-value"
# From the user perspective, both headers and trailers are accessed the same way:
headers["content-type"] # => "application/grpc+proto"
headers["grpc-status"] # => "0"
headers["custom-trailer"] # => "final-value"
# But internally, Protocol::HTTP::Headers knows which are trailers:
headers.trailer? # => true
headers.tail # => 2 (index where trailers start)
# Iterate over just the trailers:
headers.trailer.each do |key, value|
puts "#{key}: #{value}"
end
# Outputs:
# grpc-status: 0
# grpc-message: OK
# custom-trailer: final-value
# The gRPC header policy ensures grpc-status and grpc-message are allowed as trailers
# Without the policy, they would be rejected when added after trailer!()# Example 2: Trailers-Only Response (immediate error, status in headers)
# This is used when you have an error before sending any response body
headers = Protocol::HTTP::Headers.new([], nil, policy: Protocol::GRPC::HEADER_POLICY)
# Add initial headers
headers["content-type"] = "application/grpc+proto"
# Add status directly as headers (NOT trailers) - no need to call trailer!
headers["grpc-status"] = Protocol::GRPC::Status::NOT_FOUND.to_s
headers["grpc-message"] = URI.encode_www_form_component("User not found")
# No trailer!() call, no body
Protocol::HTTP::Response[200, headers, nil]
# This is a "trailers-only" response - the status is sent immediately
# without any response body. This is semantically equivalent to sending
# trailers, but more efficient when there's no data to send.protocol-grpc includes a simple code generator for service definitions.
- Use standard
protocfor messages: Don't reinvent Protocol Buffers serialization - Generate service layer only: Parse
.protofiles to extract service definitions - Generate Async::GRPC-compatible code: Client stubs and server base classes
- Minimal dependencies: Simple text parsing, no need for full protobuf compiler
require "protocol/grpc/generator"
# Generate from .proto file
generator = Protocol::GRPC::Generator.new("my_service.proto")
# Generate client stubs
generator.generate_client("lib/my_service_client.rb")
# Generate server base classes
generator.generate_server("lib/my_service_server.rb")
# Or generate both
generator.generate_all("lib/my_service_grpc.rb")syntax = "proto3";
package my_service;
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
message Point {
int32 latitude = 1;
int32 longitude = 2;
}
message RouteSummary {
int32 point_count = 1;
}
service Greeter {
// Unary RPC
rpc SayHello(HelloRequest) returns (HelloReply);
// Server streaming RPC
rpc StreamNumbers(HelloRequest) returns (stream HelloReply);
// Client streaming RPC
rpc RecordRoute(stream Point) returns (RouteSummary);
// Bidirectional streaming RPC
rpc RouteChat(stream Point) returns (stream Point);
}# Generated by Protocol::GRPC::Generator
# DO NOT EDIT
require "protocol/grpc"
require_relative "my_service_pb" # Generated by protoc --ruby_out
module MyService
# Client stub for Greeter service
class GreeterClient
# @parameter client [Async::GRPC::Client] The gRPC client
def initialize(client)
@client = client
end
SERVICE_PATH = "my_service.Greeter"
# Unary RPC: SayHello
# @parameter request [MyService::HelloRequest]
# @parameter metadata [Hash] Custom metadata
# @parameter timeout [Numeric] Deadline
# @returns [MyService::HelloReply]
def say_hello(request, metadata: {}, timeout: nil)
@client.unary(
SERVICE_PATH,
"SayHello",
request,
response_class: MyService::HelloReply,
metadata: metadata,
timeout: timeout
)
end
# Server streaming RPC: StreamNumbers
# @parameter request [MyService::HelloRequest]
# @yields {|response| ...} Each HelloReply message
# @returns [Enumerator<MyService::HelloReply>] if no block given
def stream_numbers(request, metadata: {}, timeout: nil, &block)
@client.server_streaming(
SERVICE_PATH,
"StreamNumbers",
request,
response_class: MyService::HelloReply,
metadata: metadata,
timeout: timeout,
&block
)
end
# Client streaming RPC: RecordRoute
# @yields {|stream| ...} Block that writes Point messages
# @returns [MyService::RouteSummary]
def record_route(metadata: {}, timeout: nil, &block)
@client.client_streaming(
SERVICE_PATH,
"RecordRoute",
response_class: MyService::RouteSummary,
metadata: metadata,
timeout: timeout,
&block
)
end
# Bidirectional streaming RPC: RouteChat
# @yields {|input, output| ...} input for writing, output for reading
def route_chat(metadata: {}, timeout: nil, &block)
@client.bidirectional_streaming(
SERVICE_PATH,
"RouteChat",
response_class: MyService::Point,
metadata: metadata,
timeout: timeout,
&block
)
end
end
end# Generated by Protocol::GRPC::Generator
# DO NOT EDIT
require "protocol/grpc"
require_relative "my_service_pb" # Generated by protoc --ruby_out
module MyService
# Base class for Greeter service implementation
# Inherit from this class and implement the RPC methods
class GreeterService
# Unary RPC: SayHello
# Override this method in your implementation
# @parameter request [MyService::HelloRequest]
# @parameter call [Protocol::GRPC::ServerCall] Call context with metadata
# @returns [MyService::HelloReply]
def say_hello(request, call)
raise NotImplementedError, "#{self.class}#say_hello not implemented"
end
# Server streaming RPC: StreamNumbers
# Override this method in your implementation
# @parameter request [MyService::HelloRequest]
# @parameter call [Protocol::GRPC::ServerCall] Call context with metadata
# @yields [MyService::HelloReply] Yield each response message
def stream_numbers(request, call)
raise NotImplementedError, "#{self.class}#stream_numbers not implemented"
end
# Client streaming RPC: RecordRoute
# Override this method in your implementation
# @parameter call [Protocol::GRPC::ServerCall] Call context with metadata
# @yields [MyService::Point] Each request message from client
# @returns [MyService::RouteSummary]
def record_route(call)
raise NotImplementedError, "#{self.class}#record_route not implemented"
end
# Bidirectional streaming RPC: RouteChat
# Override this method in your implementation
# @parameter call [Protocol::GRPC::ServerCall] Call context with metadata
# @returns [Enumerator, Enumerator] (input, output) - input for reading, output for writing
def route_chat(call)
raise NotImplementedError, "#{self.class}#route_chat not implemented"
end
# Internal: Dispatch method for Async::GRPC::Server
# Maps RPC calls to handler methods
def self.rpc_descriptions
{
"SayHello" => {
method: :say_hello,
request_class: MyService::HelloRequest,
response_class: MyService::HelloReply,
request_streaming: false,
response_streaming: false
},
"StreamNumbers" => {
method: :stream_numbers,
request_class: MyService::HelloRequest,
response_class: MyService::HelloReply,
request_streaming: false,
response_streaming: true
},
"RecordRoute" => {
method: :record_route,
request_class: MyService::Point,
response_class: MyService::RouteSummary,
request_streaming: true,
response_streaming: false
},
"RouteChat" => {
method: :route_chat,
request_class: MyService::Point,
response_class: MyService::Point,
request_streaming: true,
response_streaming: true
}
}
end
end
endrequire "async"
require "async/grpc/client"
require_relative "my_service_grpc"
endpoint = Async::HTTP::Endpoint.parse("https://localhost:50051")
Async do
client = Async::GRPC::Client.new(endpoint)
stub = MyService::GreeterClient.new(client)
# Clean, typed interface!
request = MyService::HelloRequest.new(name: "World")
response = stub.say_hello(request)
puts response.message
# Server streaming
stub.stream_numbers(request) do |reply|
puts reply.message
end
ensure
client.close
endrequire "async"
require "async/grpc/server"
require_relative "my_service_grpc"
# Implement the service by inheriting from generated base class
class MyGreeter < MyService::GreeterService
def say_hello(request, call)
MyService::HelloReply.new(
message: "Hello, #{request.name}!"
)
end
def stream_numbers(request, call)
10.times do |i|
yield MyService::HelloReply.new(
message: "Number #{i} for #{request.name}"
)
end
end
def record_route(call)
points = []
call.each_request do |point|
points << point
end
MyService::RouteSummary.new(
point_count: points.size
)
end
end
# Register service
Async do
server = Async::GRPC::Server.new
server.register("my_service.Greeter", MyGreeter.new)
# ... start server
endThe generator would:
- Parse
.protofiles using simple regex/text parsing (no full compiler needed) - Extract service definitions: service name, RPC methods, request/response types
- Determine streaming types: unary, server streaming, client streaming, bidirectional
- Generate Ruby code using ERB templates or string interpolation
Key classes:
module Protocol
module GRPC
class Generator
# @parameter proto_file [String] Path to .proto file
def initialize(proto_file)
@proto = parse_proto(proto_file)
end
def generate_client(output_path)
# Generate client stub
end
def generate_server(output_path)
# Generate server base class
end
private
def parse_proto(file)
# Simple parsing - extract:
# - package name
# - message names (just reference them, protoc generates these)
# - service definitions
# - RPC methods with request/response types and streaming flags
end
end
end
end# Step 1: Generate message classes with standard protoc
protoc --ruby_out=lib my_service.proto
# Step 2: Generate gRPC service layer with protocol-grpc using Bake
bake protocol:grpc:generate my_service.proto
# Or generate from all .proto files in a directory
bake protocol:grpc:generate:all# bake/protocol/grpc.rb
module Bake
module Protocol
module GRPC
# Generate gRPC service stubs from .proto file
# @parameter path [String] Path to .proto file
def generate(path)
require "protocol/grpc/generator"
generator = ::Protocol::GRPC::Generator.new(path)
output_path = path.sub(/\.proto$/, "_grpc.rb")
generator.generate_all(output_path)
Console.logger.info(self){"Generated #{output_path}"}
end
# Generate gRPC stubs for all .proto files in directory
# @parameter directory [String] Directory containing .proto files
def generate_all(directory: ".")
Dir.glob(File.join(directory, "**/*.proto")).each do |proto_file|
generate(proto_file)
end
end
end
end
endUsage:
# Generate from specific file
bake protocol:grpc:generate sample/my_service.proto
# Generate all in directory
bake protocol:grpc:generate:all
# Or from specific directory
bake protocol:grpc:generate:all directory=protos/This keeps dependencies minimal while providing great developer experience!
Protocol::GRPC::Messageinterface (✅ Designed)Protocol::GRPC::MessageHelpersfor encoding/decoding (✅ Designed)Protocol::GRPC::Statusconstants (✅ Designed)Protocol::GRPC::Errorhierarchy (✅ Designed)Protocol::GRPC::Body::Readable(framing reader) (✅ Designed)Protocol::GRPC::Body::Writable(framing writer) (✅ Designed)- Binary message support (no message_class = raw binary) (✅ Designed)
Protocol::GRPC::Methods(path parsing, header building) (✅ Designed)Protocol::GRPC::Headerclasses (Status, Message, Metadata) (✅ Designed)Protocol::GRPC::HEADER_POLICYfor trailer support (✅ Designed)Protocol::GRPC::Metadata(status extraction, trailer helpers) (✅ Designed)Protocol::GRPC::Callcontext object (✅ Designed)Protocol::GRPC::Middlewareserver (✅ Designed)- Compression support (built into Readable/Writable) (✅ Designed)
Protocol::GRPC::HealthCheckprotocol (✅ Designed)- Binary metadata support (base64 encoding for
-binheaders) (✅ Designed) - Timeout format handling (✅ Designed)
Protocol::GRPC::Generator- Parse .proto files- Generate client stubs
- Generate server base classes
- CLI tool:
bake protocol:grpc:generate
- Compression support (gzip)
- Streaming body wrappers
- Message validation helpers
async-grpc- Async client implementation + helpers- No server class needed (just use Protocol::GRPC::Middleware with Async::HTTP::Server)
- Channel adapter for Google Cloud integration
This gem provides only protocol abstractions, not client/server implementations. This follows the same pattern as protocol-http:
protocol-http→ provides HTTP abstractionsprotocol-http1→ implements HTTP/1.1 protocolprotocol-http2→ implements HTTP/2 protocolasync-http→ provides client/server using the protocols
Similarly:
protocol-grpc→ provides gRPC abstractions (this gem)async-grpc→ provides client/server implementations (separate gem)
- Reuse: gRPC runs over HTTP/2; leverage existing HTTP/2 implementations
- Separation: Keep gRPC protocol logic independent from transport concerns
- Compatibility: Works with any Protocol::HTTP-compatible implementation
- Flexibility: Users can choose their HTTP/2 client/server
gRPC uses a 5-byte prefix for each message:
- 1 byte: Compression flag (0 = uncompressed, 1 = compressed)
- 4 bytes: Message length (big-endian uint32)
This is handled by Protocol::GRPC::Body::Readable and Writable, which wrap Protocol::HTTP::Body classes.
The gem defines a simple interface for protobuf messages:
- Class method:
.decode(binary)- decode binary data to message - Instance method:
#to_protoor#encode- encode message to binary
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.
gRPC uses its own status codes (0-16), which can be transmitted in two ways:
1. Trailers-Only Response (status in initial headers):
- Used for immediate errors or responses without a body
grpc-statusis sent as an initial headergrpc-messageis sent as an initial header (if present)- HTTP status is still 200 (or 4xx/5xx in some cases)
- No response body is sent
2. Normal Response (status in trailers):
- Used for successful responses with data
- Response body contains length-prefixed messages
grpc-statusis sent as a trailer (after the body)grpc-messageis sent as a trailer if there's an error
Understanding Protocol::HTTP::Headers Trailers:
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.
From the user's perspective:
- Access:
headers["grpc-status"]works regardless of whether it's a header or trailer - The
@tailmarker is internal bookkeeping - Each header type has a
.trailer?class method that determines if it's allowed in trailers - By default, most HTTP headers are NOT allowed in trailers
gRPC Header Policy:
Protocol::GRPC defines HEADER_POLICY that extends Protocol::HTTP::Headers::POLICY:
grpc-status→ allowed BOTH as initial header AND as trailergrpc-message→ allowed BOTH as initial header AND as trailer- Custom metadata → can be sent as headers or trailers
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!().
This policy must be passed when creating headers: Protocol::HTTP::Headers.new([], nil, policy: Protocol::GRPC::HEADER_POLICY)
Custom metadata is transmitted as HTTP headers:
- Regular metadata: plain headers (e.g.,
authorization) - Binary metadata: headers ending in
-bin, base64 encoded - Reserved headers:
grpc-*,content-type,te
All four RPC patterns are supported through the protocol primitives:
- Unary: Single message written and read
- Server Streaming: Single write, multiple reads
- Client Streaming: Multiple writes, single read
- Bidirectional: Multiple writes and reads
These map naturally to Protocol::HTTP::Body::Writable and Readable.
-
Compression Negotiation (Compression already designed in Phase 2)
grpc-accept-encodingheader parsing (advertise supported encodings)- Algorithm selection logic
- Fallback to identity encoding
-
Service Descriptor Protocol
- Parse
.protofiles into descriptor objects - Used by reflection API
- Service/method metadata
- Parse
-
Health Check Protocol
- Standard health check message types
- Health status enum (SERVING, NOT_SERVING, UNKNOWN)
- Per-service health status
-
Reflection Protocol Messages
- ServerReflectionRequest/Response messages
- FileDescriptorProto support
- Service/method listing
-
Well-Known Error Details
- Standard error detail messages
google.rpc.Statuswith detailsgoogle.rpc.ErrorInfo,google.rpc.RetryInfo, etc.
-
Binary Message Support (✅ Designed)
Body::ReadableandWritablework with raw binary whenmessage_class: nil- No message_class = no decoding, just return binary
- Needed for channel adapters and gRPC-web
-
Context/Call Metadata
Protocol::GRPC::Call- represents single RPC (✅ Added)- Deadline tracking (✅ Added)
- Cancellation signals (✅ Added)
- Peer information (✅ Added)
-
Service Config
- Retry policy configuration
- Timeout configuration
- Load balancing hints
- Parse from JSON
-
Name Resolution
- Parse gRPC URIs (dns:///host:port, etc.)
- Service config discovery
-
Compression: Protocol layer or implementation?
- Protocol layer could provide
Body::Compressedwrapper - Implementation handles negotiation
- Recommendation: Protocol provides wrappers, implementation handles negotiation
- Protocol layer could provide
-
Custom Metadata Policy: Should all metadata be trailerable?
- Current: Only
grpc-statusandgrpc-messagemarked - gRPC allows most metadata in trailers
- Need to research: Which headers MUST be in initial headers?
- Current: Only
-
Binary Metadata: Auto-encode/decode
-binheaders?- Current design: Yes, automatically base64 encode/decode
- Transparent for users
- Recommendation: Keep automatic encoding
-
Message Class vs Decoder: How to specify decoding?
- Current: Pass message class, calls
.decode(data) - Alternative: Pass decoder proc
- Recommendation: Keep class-based, simple and clean
- Current: Pass message class, calls
-
Error Hierarchy: Specific classes or generic?
- Current: Specific classes per status code
- Makes rescue clauses cleaner
- Recommendation: Keep specific classes
-
Service Discovery: Should protocol-grpc include URI parsing?
- gRPC URIs:
dns:///host:port,unix:///path, etc. - Or leave to async-grpc?
- Recommendation: Basic URI parsing in protocol-grpc
- gRPC URIs: