From fa49477d2c2d5b6e115772ec17332535c1923075 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 13 Aug 2026 23:59:46 +1200 Subject: [PATCH] Introduce gRPC route helpers --- context/getting-started.md | 11 ++-- design.md | 35 +++++------ fixtures/protocol/grpc/test_middleware.rb | 6 +- guides/getting-started/readme.md | 11 ++-- lib/protocol/grpc.rb | 1 + lib/protocol/grpc/interface.rb | 4 +- lib/protocol/grpc/methods.rb | 12 +++- lib/protocol/grpc/route.rb | 48 +++++++++++++++ test/protocol/grpc/route.rb | 71 +++++++++++++++++++++++ 9 files changed, 158 insertions(+), 41 deletions(-) create mode 100644 lib/protocol/grpc/route.rb create mode 100644 test/protocol/grpc/route.rb diff --git a/context/getting-started.md b/context/getting-started.md index 8dcffb4..e2bf64a 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -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] @@ -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 ``` @@ -144,4 +144,3 @@ call.deadline.exceeded? # => false # Access peer information call.peer # => Protocol::HTTP::Address ``` - diff --git a/design.md b/design.md index 16b478e..af11715 100644 --- a/design.md +++ b/design.md @@ -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 @@ -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 @@ -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] @@ -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, @@ -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( @@ -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/) - diff --git a/fixtures/protocol/grpc/test_middleware.rb b/fixtures/protocol/grpc/test_middleware.rb index 9f020c2..ef43c87 100644 --- a/fixtures/protocol/grpc/test_middleware.rb +++ b/fixtures/protocol/grpc/test_middleware.rb @@ -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" @@ -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 diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 8dcffb4..e2bf64a 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -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] @@ -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 ``` @@ -144,4 +144,3 @@ call.deadline.exceeded? # => false # Access peer information call.peer # => Protocol::HTTP::Address ``` - diff --git a/lib/protocol/grpc.rb b/lib/protocol/grpc.rb index 89404c9..2f0ec9e 100644 --- a/lib/protocol/grpc.rb +++ b/lib/protocol/grpc.rb @@ -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" diff --git a/lib/protocol/grpc/interface.rb b/lib/protocol/grpc/interface.rb index 8aa695f..aa943df 100644 --- a/lib/protocol/grpc/interface.rb +++ b/lib/protocol/grpc/interface.rb @@ -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 @@ -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 diff --git a/lib/protocol/grpc/methods.rb b/lib/protocol/grpc/methods.rb index 267e515..534f6af 100644 --- a/lib/protocol/grpc/methods.rb +++ b/lib/protocol/grpc/methods.rb @@ -7,6 +7,7 @@ require "protocol/http" require_relative "header/timeout" +require_relative "route" module Protocol module GRPC @@ -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. diff --git a/lib/protocol/grpc/route.rb b/lib/protocol/grpc/route.rb new file mode 100644 index 0000000..70fd544 --- /dev/null +++ b/lib/protocol/grpc/route.rb @@ -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 diff --git a/test/protocol/grpc/route.rb b/test/protocol/grpc/route.rb new file mode 100644 index 0000000..4267380 --- /dev/null +++ b/test/protocol/grpc/route.rb @@ -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