From 2f26f8dc3fe3b77ec0f1f8cafc9a5c21a450e387 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 17 Aug 2026 11:47:12 +1200 Subject: [PATCH] Move header helpers to metadata --- context/getting-started.md | 5 +- design.md | 22 ++++----- guides/getting-started/readme.md | 5 +- lib/protocol/grpc.rb | 2 +- lib/protocol/grpc/call.rb | 4 +- lib/protocol/grpc/metadata.rb | 56 +++++++++++++++++++++- lib/protocol/grpc/methods.rb | 49 +++---------------- test/protocol/grpc/call.rb | 4 +- test/protocol/grpc/metadata.rb | 76 ++++++++++++++++++++++++++++++ test/protocol/grpc/methods.rb | 81 ++------------------------------ test/protocol/grpc/middleware.rb | 12 ++--- 11 files changed, 168 insertions(+), 148 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index e2bf64a..8e12834 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -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 @@ -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 diff --git a/design.md b/design.md index af11715..4bfc922 100644 --- a/design.md +++ b/design.md @@ -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 @@ -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 @@ -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" @@ -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| @@ -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 @@ -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 ) @@ -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) diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index e2bf64a..8e12834 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -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 @@ -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 diff --git a/lib/protocol/grpc.rb b/lib/protocol/grpc.rb index 2f0ec9e..f99e238 100644 --- a/lib/protocol/grpc.rb +++ b/lib/protocol/grpc.rb @@ -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" diff --git a/lib/protocol/grpc/call.rb b/lib/protocol/grpc/call.rb index d958b09..e461c62 100644 --- a/lib/protocol/grpc/call.rb +++ b/lib/protocol/grpc/call.rb @@ -4,7 +4,7 @@ # Copyright, 2025, by Samuel Williams. require "async/deadline" -require_relative "methods" +require_relative "metadata" module Protocol module GRPC @@ -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. diff --git a/lib/protocol/grpc/metadata.rb b/lib/protocol/grpc/metadata.rb index 44552f7..712cc9c 100644 --- a/lib/protocol/grpc/metadata.rb +++ b/lib/protocol/grpc/metadata.rb @@ -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. # diff --git a/lib/protocol/grpc/methods.rb b/lib/protocol/grpc/methods.rb index 534f6af..ed697bb 100644 --- a/lib/protocol/grpc/methods.rb +++ b/lib/protocol/grpc/methods.rb @@ -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 @@ -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. diff --git a/test/protocol/grpc/call.rb b/test/protocol/grpc/call.rb index 0e9d806..7c6cd64 100644 --- a/test/protocol/grpc/call.rb +++ b/test/protocol/grpc/call.rb @@ -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) @@ -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 diff --git a/test/protocol/grpc/metadata.rb b/test/protocol/grpc/metadata.rb index 03e02e3..06ba94d 100644 --- a/test/protocol/grpc/metadata.rb +++ b/test/protocol/grpc/metadata.rb @@ -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) diff --git a/test/protocol/grpc/methods.rb b/test/protocol/grpc/methods.rb index 344e00b..2e18e9a 100644 --- a/test/protocol/grpc/methods.rb +++ b/test/protocol/grpc/methods.rb @@ -23,92 +23,19 @@ end with ".build_headers" do - it "builds basic gRPC headers" do - headers = subject.build_headers - - content_type = headers["content-type"] - content_type = content_type.first if content_type.is_a?(Array) - te_value = headers["te"] - te_value = te_value.first if te_value.is_a?(Array) - - expect(content_type.to_s).to be == "application/grpc+proto" - expect(te_value.to_s).to be == "trailers" - end - - it "builds headers with metadata" do + it "delegates to Metadata.build" do headers = subject.build_headers(metadata: { "authorization" => "Bearer token123" }) - auth_value = headers["authorization"] - auth_value = auth_value.first if auth_value.is_a?(Array) - expect(auth_value.to_s).to be == "Bearer token123" - end - - it "builds headers with timeout" do - headers = subject.build_headers(timeout: 5.0) - - timeout_value = headers["grpc-timeout"] - timeout_value = timeout_value.first if timeout_value.is_a?(Array) - expect(timeout_value.to_s).to be_a(String) - expect(timeout_value.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_headers(metadata: { "custom-bin" => binary_data }) - - custom_value = headers["custom-bin"] - custom_value = custom_value.first if custom_value.is_a?(Array) - expect(custom_value.to_s).not.to be == binary_data - expect(custom_value.to_s).to be_a(String) - end - - it "allows custom content type" do - headers = subject.build_headers(content_type: "application/grpc+json") - content_type = headers["content-type"] - content_type = content_type.first if content_type.is_a?(Array) - expect(content_type.to_s).to be == "application/grpc+json" + expect(headers["authorization"].to_s).to be == "Bearer token123" end end with ".extract_metadata" 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=="] # Base64 encoded binary - ]) - end - - it "extracts metadata from headers" do + it "delegates to Metadata.extract" do + headers = Protocol::HTTP::Headers.new([["authorization", "Bearer token123"]]) metadata = subject.extract_metadata(headers) expect(metadata["authorization"]).to be == "Bearer token123" - expect(metadata["custom-header"]).to be == ["value"] - end - - it "skips reserved headers" do - metadata = subject.extract_metadata(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_metadata(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_metadata(headers) - expect(metadata["custom-bin"]).to be == "\x01\x02\x03\x04".dup.force_encoding(Encoding::BINARY) end end diff --git a/test/protocol/grpc/middleware.rb b/test/protocol/grpc/middleware.rb index 97d22dd..6163f43 100644 --- a/test/protocol/grpc/middleware.rb +++ b/test/protocol/grpc/middleware.rb @@ -276,10 +276,10 @@ def say_hello(_input, output, _call) expect(backtrace[0]).to be == "/path/to/file.rb:10:in `method'" expect(backtrace[1]).to be == "/path/to/file.rb:5:in `block'" - # Also verify it's accessible via extract_metadata (for client-side usage) - metadata = Protocol::GRPC::Methods.extract_metadata(response.headers) + # Also verify it's accessible via metadata extraction (for client-side usage) + metadata = Protocol::GRPC::Metadata.extract(response.headers) backtrace_from_metadata = metadata["backtrace"] - # extract_metadata may return string or array depending on how headers.each works + # Metadata extraction may return string or array depending on how headers.each works # But the important thing is that it's present and can be parsed expect(backtrace_from_metadata).not.to be_nil end @@ -294,7 +294,7 @@ def say_hello(_input, output, _call) error: error ) - metadata = Protocol::GRPC::Methods.extract_metadata(response.headers) + metadata = Protocol::GRPC::Metadata.extract(response.headers) expect(metadata.key?("backtrace")).to be == false end @@ -305,7 +305,7 @@ def say_hello(_input, output, _call) error: nil ) - metadata = Protocol::GRPC::Methods.extract_metadata(response.headers) + metadata = Protocol::GRPC::Metadata.extract(response.headers) expect(metadata.key?("backtrace")).to be == false end @@ -319,7 +319,7 @@ def say_hello(_input, output, _call) error: error ) - metadata = Protocol::GRPC::Methods.extract_metadata(response.headers) + metadata = Protocol::GRPC::Metadata.extract(response.headers) expect(metadata.key?("backtrace")).to be == false end