Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,10 @@ rpc :SayHelloAgain, request_class: Hello::HelloRequest, response_class: Hello::H

### Building a Request

Build gRPC requests using `Protocol::GRPC::Methods` and `Protocol::GRPC::Body::Writable`:
Build gRPC requests using `Protocol::GRPC::Metadata`, `Protocol::GRPC::Route`, and `Protocol::GRPC::Body::Writable`:

``` ruby
require "protocol/grpc"
require "protocol/grpc/methods"
require "protocol/grpc/body/writable"

# Build request body
Expand All @@ -75,7 +74,7 @@ body.write(Hello::HelloRequest.new(name: "World"))
body.close_write

# Build headers
headers = Protocol::GRPC::Methods.build_headers(timeout: 5.0)
headers = Protocol::GRPC::Metadata.build(timeout: 5.0)
path = Protocol::GRPC::Route.build("hello.Greeter", "SayHello")

# Create HTTP request
Expand Down
22 changes: 11 additions & 11 deletions design.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ It does NOT include:
The protocol layer provides these core abstractions:

1. **Message Interface** - `Protocol::GRPC::Message` and `MessageHelpers`
2. **Path Handling** - `Protocol::GRPC::Route` (build/parse paths) and `Protocol::GRPC::Methods` (headers and metadata)
3. **Metadata** - `Protocol::GRPC::Metadata` (extract status, build trailers)
2. **Path Handling** - `Protocol::GRPC::Route` (build and parse request paths)
3. **Metadata** - `Protocol::GRPC::Metadata` (build request headers and extract or assign metadata)
4. **Body Framing** - `Protocol::GRPC::Body::Readable` and `Body::Writable`
5. **Status Codes** - `Protocol::GRPC::Status` constants
6. **Errors** - `Protocol::GRPC::Error` hierarchy
Expand Down Expand Up @@ -122,9 +122,9 @@ end

**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.

#### 2. `Protocol::GRPC::Route` and `Protocol::GRPC::Methods`
#### 2. `Protocol::GRPC::Route` and `Protocol::GRPC::Metadata`

`Route` represents the service and method encoded in a gRPC request path. `Methods` retains the header and metadata helpers:
`Route` represents the service and method encoded in a gRPC request path. `Metadata` builds request headers and extracts application metadata:

```ruby
module Protocol
Expand All @@ -139,12 +139,12 @@ module Protocol
end
end

module Methods
module Metadata
# 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")
def self.build(metadata: {}, timeout: nil, content_type: "application/grpc+proto")
headers = Protocol::HTTP::Headers.new
headers["content-type"] = content_type
headers["te"] = "trailers"
Expand All @@ -165,7 +165,7 @@ module Protocol
# Extract metadata from gRPC headers
# @parameter headers [Protocol::HTTP::Headers]
# @returns [Hash] Metadata key-value pairs
def self.extract_metadata(headers)
def self.extract(headers)
metadata = {}

headers.each do |key, value|
Expand Down Expand Up @@ -547,7 +547,7 @@ module Protocol
# Extract metadata from request headers
# @returns [Hash] Custom metadata
def metadata
@metadata ||= Methods.extract_metadata(@request.headers)
@metadata ||= Metadata.extract(@request.headers)
end

# Check if the deadline has expired
Expand Down Expand Up @@ -830,7 +830,7 @@ body.write(MyService::HelloRequest.new(name: "World"))
body.close_write

# Build gRPC headers
headers = Protocol::GRPC::Methods.build_headers(
headers = Protocol::GRPC::Metadata.build(
metadata: {"authorization" => "Bearer token123"},
timeout: 5.0
)
Expand Down Expand Up @@ -1430,8 +1430,8 @@ This keeps dependencies minimal while providing great developer experience!
- Binary message support (no message_class = raw binary) (✅ Designed)

### Phase 2: Protocol Helpers
- `Protocol::GRPC::Methods` (path parsing, header building) (✅ Designed)
- `Protocol::GRPC::Header` classes (Status, Message, Metadata) (✅ Designed)
- `Protocol::GRPC::Route` (path parsing and building) (✅ Designed)
- `Protocol::GRPC::Header` values (Status, Message, Timeout, Encoding) (✅ Designed)
- `Protocol::GRPC::HEADER_POLICY` for trailer support (✅ Designed)
- `Protocol::GRPC::Metadata` (status extraction, trailer helpers) (✅ Designed)
- `Protocol::GRPC::Call` context object (✅ Designed)
Expand Down
5 changes: 2 additions & 3 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,10 @@ rpc :SayHelloAgain, request_class: Hello::HelloRequest, response_class: Hello::H

### Building a Request

Build gRPC requests using `Protocol::GRPC::Methods` and `Protocol::GRPC::Body::Writable`:
Build gRPC requests using `Protocol::GRPC::Metadata`, `Protocol::GRPC::Route`, and `Protocol::GRPC::Body::Writable`:

``` ruby
require "protocol/grpc"
require "protocol/grpc/methods"
require "protocol/grpc/body/writable"

# Build request body
Expand All @@ -75,7 +74,7 @@ body.write(Hello::HelloRequest.new(name: "World"))
body.close_write

# Build headers
headers = Protocol::GRPC::Methods.build_headers(timeout: 5.0)
headers = Protocol::GRPC::Metadata.build(timeout: 5.0)
path = Protocol::GRPC::Route.build("hello.Greeter", "SayHello")

# Create HTTP request
Expand Down
2 changes: 1 addition & 1 deletion lib/protocol/grpc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
require_relative "grpc/status"
require_relative "grpc/error"
require_relative "grpc/route"
require_relative "grpc/methods"
require_relative "grpc/header"
require_relative "grpc/metadata"
require_relative "grpc/methods"
require_relative "grpc/call"
require_relative "grpc/body/readable"
require_relative "grpc/body/writable"
Expand Down
4 changes: 2 additions & 2 deletions lib/protocol/grpc/call.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Copyright, 2025, by Samuel Williams.

require "async/deadline"
require_relative "methods"
require_relative "metadata"

module Protocol
module GRPC
Expand Down Expand Up @@ -46,7 +46,7 @@ def initialize(request, response = nil, deadline: nil)
# Extract metadata from request headers.
# @returns [Hash] Custom metadata key-value pairs
def metadata
@metadata ||= Methods.extract_metadata(@request.headers)
@metadata ||= Metadata.extract(@request.headers)
end

# Get the timeout requested by the client.
Expand Down
56 changes: 55 additions & 1 deletion lib/protocol/grpc/metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,67 @@
# Released under the MIT License.
# Copyright, 2025-2026, by Samuel Williams.

require "base64"

require_relative "header"
require_relative "status"

module Protocol
module GRPC
# @namespace
# Provides operations for building and extracting gRPC metadata.
module Metadata
# Build gRPC request headers containing the given metadata.
# @parameter metadata [Hash] Custom metadata key-value pairs.
# @parameter timeout [Numeric | Nil] Optional timeout in seconds.
# @parameter content_type [String] The request content type.
# @returns [Protocol::HTTP::Headers] The constructed request headers.
def self.build(metadata: {}, timeout: nil, content_type: "application/grpc+proto")
headers = Protocol::HTTP::Headers.new(policy: Protocol::GRPC::HEADER_POLICY)
headers["content-type"] = content_type
headers["te"] = "trailers"

if timeout
# Coerced to proper format by header policy:
headers["grpc-timeout"] = timeout
end

metadata.each do |key, value|
# Binary headers end with -bin and are base64 encoded:
headers[key] = if key.end_with?("-bin")
Base64.strict_encode64(value)
else
value.to_s
end
end

headers
end

# Extract application metadata from gRPC headers.
# @parameter headers [Protocol::HTTP::Headers] The headers to inspect.
# @returns [Hash] The extracted metadata key-value pairs.
def self.extract(headers)
metadata = {}

headers.to_h.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")
if value.is_a?(String)
value = Base64.strict_decode64(value)
elsif value.is_a?(Array)
value = value.map{|item| Base64.strict_decode64(item)}
end
end

metadata[key] = value
end

metadata
end

# Extract gRPC status from headers.
# Returns Status::UNKNOWN if status is not present.
#
Expand Down
49 changes: 7 additions & 42 deletions lib/protocol/grpc/methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,7 @@
# Released under the MIT License.
# Copyright, 2025-2026, by Samuel Williams.

require "base64"
require "protocol/http"

require_relative "header/timeout"
require_relative "metadata"
require_relative "route"

module Protocol
Expand Down Expand Up @@ -39,53 +36,21 @@ def self.parse_path(path)
# @parameter timeout [Numeric | Nil] Optional timeout in seconds
# @parameter content_type [String] Content type (default: "application/grpc+proto")
# @returns [Protocol::HTTP::Headers]
# @deprecated Use {Metadata.build} instead.
def self.build_headers(metadata: {}, timeout: nil, content_type: "application/grpc+proto")
headers = Protocol::HTTP::Headers.new(policy: Protocol::GRPC::HEADER_POLICY)
headers["content-type"] = content_type
headers["te"] = "trailers"

if timeout
# Coerced to proper format by header policy:
headers["grpc-timeout"] = timeout
end
Kernel.warn("`Protocol::GRPC::Methods.build_headers` is deprecated; use `Protocol::GRPC::Metadata.build` instead.", uplevel: 1, category: :deprecated) if $VERBOSE

metadata.each do |key, value|
# Binary headers end with -bin and are base64 encoded:
headers[key] = if key.end_with?("-bin")
Base64.strict_encode64(value)
else
value.to_s
end
end

headers
Metadata.build(metadata: metadata, timeout: timeout, content_type: content_type)
end

# Extract metadata from gRPC headers.
# @parameter headers [Protocol::HTTP::Headers]
# @returns [Hash] Metadata key-value pairs
# @deprecated Use {Metadata.extract} instead.
def self.extract_metadata(headers)
metadata = {}

headers.to_h.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")
if value.is_a?(String)
value = Base64.strict_decode64(value)
elsif value.is_a?(Array)
value = value.map{|item| Base64.strict_decode64(item)}
end
else
value
end

metadata[key] = value
end
Kernel.warn("`Protocol::GRPC::Methods.extract_metadata` is deprecated; use `Protocol::GRPC::Metadata.extract` instead.", uplevel: 1, category: :deprecated) if $VERBOSE

metadata
Metadata.extract(headers)
end

# Format timeout for grpc-timeout header.
Expand Down
4 changes: 2 additions & 2 deletions test/protocol/grpc/call.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
end

it "computes deadline from grpc-timeout" do
headers = Protocol::GRPC::Methods.build_headers(timeout: 0.3)
headers = Protocol::GRPC::Metadata.build(timeout: 0.3)
request = Protocol::HTTP::Request.new("https", "localhost", "POST", "/service/method", nil, headers, nil)
call = subject.for(request, response)

Expand Down Expand Up @@ -51,7 +51,7 @@

with "timeout" do
it "returns the client supplied timeout in seconds" do
headers = Protocol::GRPC::Methods.build_headers(timeout: 0.3)
headers = Protocol::GRPC::Metadata.build(timeout: 0.3)
request = Protocol::HTTP::Request.new("https", "localhost", "POST", "/service/method", nil, headers, nil)
call = subject.new(request)
expect(call.timeout).to be == 0.3
Expand Down
76 changes: 76 additions & 0 deletions test/protocol/grpc/metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,82 @@
require "protocol/http"

describe Protocol::GRPC::Metadata do
with ".build" do
it "builds basic gRPC headers" do
headers = subject.build

expect(headers["content-type"].to_s).to be == "application/grpc+proto"
expect(headers["te"].to_s).to be == "trailers"
end

it "builds headers with metadata" do
headers = subject.build(metadata: {"authorization" => "Bearer token123"})

expect(headers["authorization"].to_s).to be == "Bearer token123"
end

it "builds headers with timeout" do
headers = subject.build(timeout: 5.0)

expect(headers["grpc-timeout"].to_s).to be =~ /\d+[SMHmun]/
end

it "encodes binary metadata" do
binary_data = "\x00\x01\x02\x03".dup.force_encoding(Encoding::BINARY)
headers = subject.build(metadata: {"custom-bin" => binary_data})

expect(headers["custom-bin"].to_s).not.to be == binary_data
end

it "allows a custom content type" do
headers = subject.build(content_type: "application/grpc+json")

expect(headers["content-type"].to_s).to be == "application/grpc+json"
end
end

with ".extract" do
let(:headers) do
Protocol::HTTP::Headers.new([
["content-type", "application/grpc+proto"],
["authorization", "Bearer token123"],
["custom-header", "value"],
["grpc-status", "0"],
["custom-bin", "AQIDBA=="]
])
end

it "extracts application metadata" do
metadata = subject.extract(headers)

expect(metadata["authorization"]).to be == "Bearer token123"
expect(metadata["custom-header"]).to be == ["value"]
end

it "skips reserved headers" do
metadata = subject.extract(headers)

expect(metadata.key?("content-type")).to be == false
expect(metadata.key?("grpc-status")).to be == false
end

it "decodes binary metadata" do
metadata = subject.extract(headers)

expect(metadata["custom-bin"]).to be == ["\x01\x02\x03\x04".dup.force_encoding(Encoding::BINARY)]
end

it "decodes scalar binary metadata" do
headers = Object.new
def headers.to_h
{"custom-bin" => "AQIDBA=="}
end

metadata = subject.extract(headers)
expect(metadata["custom-bin"]).to be == "\x01\x02\x03\x04".dup.force_encoding(Encoding::BINARY)
end
end

with ".extract_status" do
it "extracts status from headers" do
headers = Protocol::HTTP::Headers.new([%w[grpc-status 0]], nil, policy: Protocol::GRPC::HEADER_POLICY)
Expand Down
Loading