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
11 changes: 5 additions & 6 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ body.close_write

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

# Create HTTP request
request = Protocol::HTTP::Request["POST", path, headers, body]
Expand Down Expand Up @@ -117,11 +117,11 @@ class MyMiddleware < Protocol::GRPC::Middleware
protected

def dispatch(request)
# Parse service and method from path
service_name, method_name = Protocol::GRPC::Methods.parse_path(request.path)
# Parse the service and method from the path:
service_name, method_name = Protocol::GRPC::Route.parse(request.path)

# Handle the request and return a response
# ...
# Handle the request using service_name and method_name.
# ...
end
end
```
Expand All @@ -144,4 +144,3 @@ call.deadline.exceeded? # => false
# Access peer information
call.peer # => Protocol::HTTP::Address
```

35 changes: 14 additions & 21 deletions design.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ It does NOT include:
The protocol layer provides these core abstractions:

1. **Message Interface** - `Protocol::GRPC::Message` and `MessageHelpers`
2. **Path Handling** - `Protocol::GRPC::Methods` (build/parse paths, headers, timeouts)
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)
4. **Body Framing** - `Protocol::GRPC::Body::Readable` and `Body::Writable`
5. **Status Codes** - `Protocol::GRPC::Status` constants
Expand Down Expand Up @@ -122,30 +122,24 @@ 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::Methods`
#### 2. `Protocol::GRPC::Route` and `Protocol::GRPC::Methods`

Helper module for building gRPC-compatible HTTP requests:
`Route` represents the service and method encoded in a gRPC request path. `Methods` retains the header and metadata helpers:

```ruby
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}"
module Route
def self.parse(path)
# Return the service and method names.
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]]
def self.build(service_name, method_name)
# Return the gRPC request path.
end

end

module Methods
# Build gRPC request headers
# @parameter metadata [Hash] Custom metadata key-value pairs
# @parameter timeout [Numeric] Optional timeout in seconds
Expand Down Expand Up @@ -719,7 +713,7 @@ module Protocol
end

# Parse service and method from path
service_name, method_name = Methods.parse_path(request.path)
service_name, method_name = Route.parse(request.path)

# Find handler
handler = @services[service_name]
Expand Down Expand Up @@ -842,7 +836,7 @@ headers = Protocol::GRPC::Methods.build_headers(
)

# Create HTTP request with gRPC path
path = Protocol::GRPC::Methods.build_path("my_service.Greeter", "SayHello")
path = Protocol::GRPC::Route.build("my_service.Greeter", "SayHello")

request = Protocol::HTTP::Request[
"POST", path,
Expand Down Expand Up @@ -895,7 +889,7 @@ require "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)
service, method = Protocol::GRPC::Route.parse(http_request.path)

# Read input messages
input = Protocol::GRPC::Body::Readable.new(
Expand Down Expand Up @@ -1644,4 +1638,3 @@ These map naturally to `Protocol::HTTP::Body::Writable` and `Readable`.
- [gRPC Protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md)
- [Protocol::HTTP Design](https://socketry.github.io/protocol-http/guides/design-overview/)
- [gRPC over HTTP/2](https://grpc.io/docs/what-is-grpc/core-concepts/)

6 changes: 3 additions & 3 deletions fixtures/protocol/grpc/test_middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# Copyright, 2025-2026, by Samuel Williams.

require "protocol/grpc/middleware"
require "protocol/grpc/methods"
require "protocol/grpc/route"
require "protocol/grpc/call"
require "protocol/grpc/body/readable"
require "protocol/grpc/body/writable"
Expand All @@ -21,8 +21,8 @@ def initialize(app = nil, service_handler: nil, services: nil)
protected

def dispatch(request)
# Parse service and method from path
service_name, method_name = Protocol::GRPC::Methods.parse_path(request.path)
# Parse service and method from path:
service_name, method_name = Protocol::GRPC::Route.parse(request.path)

# Find service handler
service_handler = if @services
Expand Down
11 changes: 5 additions & 6 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ body.close_write

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

# Create HTTP request
request = Protocol::HTTP::Request["POST", path, headers, body]
Expand Down Expand Up @@ -117,11 +117,11 @@ class MyMiddleware < Protocol::GRPC::Middleware
protected

def dispatch(request)
# Parse service and method from path
service_name, method_name = Protocol::GRPC::Methods.parse_path(request.path)
# Parse the service and method from the path:
service_name, method_name = Protocol::GRPC::Route.parse(request.path)

# Handle the request and return a response
# ...
# Handle the request using service_name and method_name.
# ...
end
end
```
Expand All @@ -144,4 +144,3 @@ call.deadline.exceeded? # => false
# Access peer information
call.peer # => Protocol::HTTP::Address
```

1 change: 1 addition & 0 deletions lib/protocol/grpc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

require_relative "grpc/status"
require_relative "grpc/error"
require_relative "grpc/route"
require_relative "grpc/methods"
require_relative "grpc/header"
require_relative "grpc/metadata"
Expand Down
4 changes: 2 additions & 2 deletions lib/protocol/grpc/interface.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# Released under the MIT License.
# Copyright, 2025-2026, by Samuel Williams.

require_relative "methods"
require_relative "route"

module Protocol
module GRPC
Expand Down Expand Up @@ -159,7 +159,7 @@ def initialize(name)
# @parameter method_name [String | Symbol] Method name in PascalCase (e.g., :SayHello)
# @returns [String] gRPC path with PascalCase method name
def path(method_name)
Methods.build_path(@name, method_name.to_s)
Route.build(@name, method_name.to_s)
end

private
Expand Down
12 changes: 9 additions & 3 deletions lib/protocol/grpc/methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
require "protocol/http"

require_relative "header/timeout"
require_relative "route"

module Protocol
module GRPC
Expand All @@ -16,16 +17,21 @@ module Methods
# @parameter service [String] e.g., "my_service.Greeter"
# @parameter method [String] e.g., "SayHello"
# @returns [String] e.g., "/my_service.Greeter/SayHello"
# @deprecated Use {Route.build} instead.
def self.build_path(service, method)
"/#{service}/#{method}"
Kernel.warn("`Protocol::GRPC::Methods.build_path` is deprecated; use `Protocol::GRPC::Route.build` instead.", uplevel: 1, category: :deprecated) if $VERBOSE

Route.build(service, method)
end

# Parse service and method from gRPC path.
# @parameter path [String] e.g., "/my_service.Greeter/SayHello"
# @returns [Array(String | String)] [service, method]
# @deprecated Use {Route.parse} instead.
def self.parse_path(path)
parts = path.split("/")
[parts[1], parts[2]]
Kernel.warn("`Protocol::GRPC::Methods.parse_path` is deprecated; use `Protocol::GRPC::Route.parse` instead.", uplevel: 1, category: :deprecated) if $VERBOSE

Route.parse(path)
end

# Build gRPC request headers.
Expand Down
48 changes: 48 additions & 0 deletions lib/protocol/grpc/route.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

module Protocol
module GRPC
# Provides operations for parsing and building gRPC request paths.
module Route
IDENTIFIER_PATTERN = "[A-Za-z][A-Za-z0-9_]*"
SERVICE_PATTERN = /\A#{IDENTIFIER_PATTERN}(?:\.#{IDENTIFIER_PATTERN})*\z/
METHOD_PATTERN = /\A#{IDENTIFIER_PATTERN}\z/
PATTERN = %r{\A/(#{IDENTIFIER_PATTERN}(?:\.#{IDENTIFIER_PATTERN})*)/(#{IDENTIFIER_PATTERN})\z}
private_constant :IDENTIFIER_PATTERN, :SERVICE_PATTERN, :METHOD_PATTERN, :PATTERN

# Parse a gRPC request path into its service and method names.
# @parameter path [String] The gRPC request path.
# @returns [Array(String)] The service and method names.
# @raises [ArgumentError] If the path does not contain valid protobuf service and method names.
def self.parse(path)
match = PATTERN.match(path) if path.is_a?(String)

unless match
raise ArgumentError, "Invalid gRPC route: #{path.inspect}"
end

[match[1], match[2]]
end

# Build a gRPC request path from its service and method names.
# @parameter service_name [String] The fully qualified service name.
# @parameter method_name [String] The method name.
# @returns [String] The gRPC request path.
# @raises [ArgumentError] If either component is not a valid protobuf service or method name.
def self.build(service_name, method_name)
unless service_name.is_a?(String) && SERVICE_PATTERN.match?(service_name)
raise ArgumentError, "Invalid gRPC service name: #{service_name.inspect}"
end

unless method_name.is_a?(String) && METHOD_PATTERN.match?(method_name)
raise ArgumentError, "Invalid gRPC method name: #{method_name.inspect}"
end

"/#{service_name}/#{method_name}"
end
end
end
end
71 changes: 71 additions & 0 deletions test/protocol/grpc/route.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

# Released under the MIT License.
# Copyright, 2026, by Samuel Williams.

require "protocol/grpc/route"

describe Protocol::GRPC::Route do
with ".parse" do
it "parses a gRPC request path" do
service_name, method_name = subject.parse("/hello_v1.Greeter2/Say_Hello2")

expect(service_name).to be == "hello_v1.Greeter2"
expect(method_name).to be == "Say_Hello2"
end

it "rejects malformed paths" do
expect do
subject.parse("/hello.Greeter")
end.to raise_exception(ArgumentError)

expect do
subject.parse(nil)
end.to raise_exception(ArgumentError)

expect do
subject.parse("/hello.Greeter/SayHello?verbose=true")
end.to raise_exception(ArgumentError)

expect do
subject.parse("/hello%2EGreeter/SayHello")
end.to raise_exception(ArgumentError)

expect do
subject.parse("/hello.Greeter/Say.Hello")
end.to raise_exception(ArgumentError)
end
end

with ".build" do
it "builds a gRPC request path" do
expect(subject.build("hello_v1.Greeter2", "Say_Hello2")).to be == "/hello_v1.Greeter2/Say_Hello2"
end

it "rejects invalid components" do
expect do
subject.build("", "SayHello")
end.to raise_exception(ArgumentError)

expect do
subject.build("hello.Greeter", "Say/Hello")
end.to raise_exception(ArgumentError)

expect do
subject.build("hello.Greeter", "SayHello?verbose=true")
end.to raise_exception(ArgumentError)

expect do
subject.build("hello service.Greeter", "SayHello")
end.to raise_exception(ArgumentError)

expect do
subject.build("hello.Greeter", "Say.Hello")
end.to raise_exception(ArgumentError)

expect do
subject.build("_hello.Greeter", "SayHello")
end.to raise_exception(ArgumentError)
end
end
end