protocol-grpc 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 506e98d8cefc5e0357e9e59331d2fd1c8e6ee830b08ccade7650e5e1956dbd83
4
- data.tar.gz: f1dfa3ff52d82e1c9db41d3536864107cc1bbe58de4a0d4ee4cec0021862b739
3
+ metadata.gz: a991dcf85eaa66f7a1cbd8d31c919e82b37e0b2034ad594da58d342391b00590
4
+ data.tar.gz: 0fe139aeffb04fbaee3700bcfde98588625dc37706195598fa99dd632786396a
5
5
  SHA512:
6
- metadata.gz: c41ddca4368db85c9d18d9c327147104f2a8c62193b9e4488ff90d662a94bc37bb875bb43e19285b093d5554377bc28fd6c4595e447684a984bbc7cb480d841e
7
- data.tar.gz: 67935af35d49bc8227280862b82c1823e43fc065487d59fa19759e293d7d6c3569b9955b4c621ae16b202dbb17288fbc2680ff9a7ea0659c28a1f35208bdd82b
6
+ metadata.gz: cbd273eb5cb2481235b14404bdc2742f95b3dee7a53383e1a69926e5b93508e2701bbba165d26023953b73cd9cc1438872d3fffa9dd1e77f2c5f0116c2ec95ce
7
+ data.tar.gz: 7e532651a9629d38558c1078eb98fb97b13c4d3c999d13ef22e01d8f6e31822bfdd10c11ce7e9640eb42db393f3441420e804fc7c6d6b74ddcdc49b3ceef560e
checksums.yaml.gz.sig CHANGED
Binary file
@@ -62,11 +62,10 @@ rpc :SayHelloAgain, request_class: Hello::HelloRequest, response_class: Hello::H
62
62
 
63
63
  ### Building a Request
64
64
 
65
- Build gRPC requests using `Protocol::GRPC::Methods` and `Protocol::GRPC::Body::Writable`:
65
+ Build gRPC requests using `Protocol::GRPC::Metadata`, `Protocol::GRPC::Route`, and `Protocol::GRPC::Body::Writable`:
66
66
 
67
67
  ``` ruby
68
68
  require "protocol/grpc"
69
- require "protocol/grpc/methods"
70
69
  require "protocol/grpc/body/writable"
71
70
 
72
71
  # Build request body
@@ -75,8 +74,8 @@ body.write(Hello::HelloRequest.new(name: "World"))
75
74
  body.close_write
76
75
 
77
76
  # Build headers
78
- headers = Protocol::GRPC::Methods.build_headers(timeout: 5.0)
79
- path = Protocol::GRPC::Methods.build_path("hello.Greeter", "SayHello")
77
+ headers = Protocol::GRPC::Metadata.build(timeout: 5.0)
78
+ path = Protocol::GRPC::Route.build("hello.Greeter", "SayHello")
80
79
 
81
80
  # Create HTTP request
82
81
  request = Protocol::HTTP::Request["POST", path, headers, body]
@@ -117,11 +116,11 @@ class MyMiddleware < Protocol::GRPC::Middleware
117
116
  protected
118
117
 
119
118
  def dispatch(request)
120
- # Parse service and method from path
121
- service_name, method_name = Protocol::GRPC::Methods.parse_path(request.path)
119
+ # Parse the service and method from the path:
120
+ service_name, method_name = Protocol::GRPC::Route.parse(request.path)
122
121
 
123
- # Handle the request and return a response
124
- # ...
122
+ # Handle the request using service_name and method_name.
123
+ # ...
125
124
  end
126
125
  end
127
126
  ```
@@ -144,4 +143,3 @@ call.deadline.exceeded? # => false
144
143
  # Access peer information
145
144
  call.peer # => Protocol::HTTP::Address
146
145
  ```
147
-
data/context/index.yaml CHANGED
@@ -3,6 +3,8 @@
3
3
  ---
4
4
  description: Protocol abstractions for gRPC, built on top of protocol-http.
5
5
  metadata:
6
+ bug_tracker_uri: https://github.com/socketry/protocol-grpc/issues
7
+ changelog_uri: https://github.com/socketry/protocol-grpc/blob/main/releases.md
6
8
  documentation_uri: https://socketry.github.io/protocol-grpc/
7
9
  source_code_uri: https://github.com/socketry/protocol-grpc.git
8
10
  files:
data/design.md CHANGED
@@ -32,8 +32,8 @@ It does NOT include:
32
32
  The protocol layer provides these core abstractions:
33
33
 
34
34
  1. **Message Interface** - `Protocol::GRPC::Message` and `MessageHelpers`
35
- 2. **Path Handling** - `Protocol::GRPC::Methods` (build/parse paths, headers, timeouts)
36
- 3. **Metadata** - `Protocol::GRPC::Metadata` (extract status, build trailers)
35
+ 2. **Path Handling** - `Protocol::GRPC::Route` (build and parse request paths)
36
+ 3. **Metadata** - `Protocol::GRPC::Metadata` (build request headers and extract or assign metadata)
37
37
  4. **Body Framing** - `Protocol::GRPC::Body::Readable` and `Body::Writable`
38
38
  5. **Status Codes** - `Protocol::GRPC::Status` constants
39
39
  6. **Errors** - `Protocol::GRPC::Error` hierarchy
@@ -122,35 +122,29 @@ end
122
122
 
123
123
  **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.
124
124
 
125
- #### 2. `Protocol::GRPC::Methods`
125
+ #### 2. `Protocol::GRPC::Route` and `Protocol::GRPC::Metadata`
126
126
 
127
- Helper module for building gRPC-compatible HTTP requests:
127
+ `Route` represents the service and method encoded in a gRPC request path. `Metadata` builds request headers and extracts application metadata:
128
128
 
129
129
  ```ruby
130
130
  module Protocol
131
131
  module GRPC
132
- module Methods
133
- # Build gRPC path from service and method
134
- # @parameter service [String] e.g., "my_service.Greeter"
135
- # @parameter method [String] e.g., "SayHello"
136
- # @returns [String] e.g., "/my_service.Greeter/SayHello"
137
- def self.build_path(service, method)
138
- "/#{service}/#{method}"
132
+ module Route
133
+ def self.parse(path)
134
+ # Return the service and method names.
139
135
  end
140
136
 
141
- # Parse service and method from gRPC path
142
- # @parameter path [String] e.g., "/my_service.Greeter/SayHello"
143
- # @returns [Tuple(String, String)] of service and method.
144
- def self.parse_path(path)
145
- parts = path.split("/")
146
- [parts[1], parts[2]]
137
+ def self.build(service_name, method_name)
138
+ # Return the gRPC request path.
147
139
  end
148
-
140
+ end
141
+
142
+ module Metadata
149
143
  # Build gRPC request headers
150
144
  # @parameter metadata [Hash] Custom metadata key-value pairs
151
145
  # @parameter timeout [Numeric] Optional timeout in seconds
152
146
  # @returns [Protocol::HTTP::Headers]
153
- def self.build_headers(metadata: {}, timeout: nil, content_type: "application/grpc+proto")
147
+ def self.build(metadata: {}, timeout: nil, content_type: "application/grpc+proto")
154
148
  headers = Protocol::HTTP::Headers.new
155
149
  headers["content-type"] = content_type
156
150
  headers["te"] = "trailers"
@@ -171,7 +165,7 @@ module Protocol
171
165
  # Extract metadata from gRPC headers
172
166
  # @parameter headers [Protocol::HTTP::Headers]
173
167
  # @returns [Hash] Metadata key-value pairs
174
- def self.extract_metadata(headers)
168
+ def self.extract(headers)
175
169
  metadata = {}
176
170
 
177
171
  headers.each do |key, value|
@@ -553,7 +547,7 @@ module Protocol
553
547
  # Extract metadata from request headers
554
548
  # @returns [Hash] Custom metadata
555
549
  def metadata
556
- @metadata ||= Methods.extract_metadata(@request.headers)
550
+ @metadata ||= Metadata.extract(@request.headers)
557
551
  end
558
552
 
559
553
  # Check if the deadline has expired
@@ -719,7 +713,7 @@ module Protocol
719
713
  end
720
714
 
721
715
  # Parse service and method from path
722
- service_name, method_name = Methods.parse_path(request.path)
716
+ service_name, method_name = Route.parse(request.path)
723
717
 
724
718
  # Find handler
725
719
  handler = @services[service_name]
@@ -836,13 +830,13 @@ body.write(MyService::HelloRequest.new(name: "World"))
836
830
  body.close_write
837
831
 
838
832
  # Build gRPC headers
839
- headers = Protocol::GRPC::Methods.build_headers(
833
+ headers = Protocol::GRPC::Metadata.build(
840
834
  metadata: {"authorization" => "Bearer token123"},
841
835
  timeout: 5.0
842
836
  )
843
837
 
844
838
  # Create HTTP request with gRPC path
845
- path = Protocol::GRPC::Methods.build_path("my_service.Greeter", "SayHello")
839
+ path = Protocol::GRPC::Route.build("my_service.Greeter", "SayHello")
846
840
 
847
841
  request = Protocol::HTTP::Request[
848
842
  "POST", path,
@@ -895,7 +889,7 @@ require "protocol/grpc"
895
889
  # This would be inside a Rack/HTTP middleware/handler
896
890
  def handle_grpc_request(http_request)
897
891
  # Parse gRPC path
898
- service, method = Protocol::GRPC::Methods.parse_path(http_request.path)
892
+ service, method = Protocol::GRPC::Route.parse(http_request.path)
899
893
 
900
894
  # Read input messages
901
895
  input = Protocol::GRPC::Body::Readable.new(
@@ -1436,8 +1430,8 @@ This keeps dependencies minimal while providing great developer experience!
1436
1430
  - Binary message support (no message_class = raw binary) (✅ Designed)
1437
1431
 
1438
1432
  ### Phase 2: Protocol Helpers
1439
- - `Protocol::GRPC::Methods` (path parsing, header building) (✅ Designed)
1440
- - `Protocol::GRPC::Header` classes (Status, Message, Metadata) (✅ Designed)
1433
+ - `Protocol::GRPC::Route` (path parsing and building) (✅ Designed)
1434
+ - `Protocol::GRPC::Header` values (Status, Message, Timeout, Encoding) (✅ Designed)
1441
1435
  - `Protocol::GRPC::HEADER_POLICY` for trailer support (✅ Designed)
1442
1436
  - `Protocol::GRPC::Metadata` (status extraction, trailer helpers) (✅ Designed)
1443
1437
  - `Protocol::GRPC::Call` context object (✅ Designed)
@@ -1644,4 +1638,3 @@ These map naturally to `Protocol::HTTP::Body::Writable` and `Readable`.
1644
1638
  - [gRPC Protocol](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md)
1645
1639
  - [Protocol::HTTP Design](https://socketry.github.io/protocol-http/guides/design-overview/)
1646
1640
  - [gRPC over HTTP/2](https://grpc.io/docs/what-is-grpc/core-concepts/)
1647
-
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "protocol/http"
7
7
  require "protocol/http/body/wrapper"
8
8
  require "zlib"
9
9
 
10
+ require_relative "../error"
11
+ require_relative "../status"
12
+
10
13
  module Protocol
11
14
  module GRPC
12
15
  # @namespace
@@ -47,8 +50,6 @@ module Protocol
47
50
  # Overrides Wrapper#read to transform raw HTTP body chunks into decoded gRPC messages.
48
51
  # @returns [Object | String | Nil] Decoded message, raw binary, or `Nil` if stream ended
49
52
  def read
50
- return nil if @body.nil? || @body.empty?
51
-
52
53
  # Read 5-byte prefix: 1 byte compression flag + 4 bytes length
53
54
  prefix = read_exactly(5)
54
55
  return nil unless prefix
@@ -58,7 +59,9 @@ module Protocol
58
59
 
59
60
  # Read the message body:
60
61
  data = read_exactly(length)
61
- return nil unless data
62
+ unless data
63
+ raise Error.new(Status::INTERNAL, "Truncated gRPC frame: expected #{length} bytes, received 0")
64
+ end
62
65
 
63
66
  # Decompress if needed:
64
67
  data = decompress(data) if compressed
@@ -76,19 +79,25 @@ module Protocol
76
79
  private
77
80
 
78
81
  # Read exactly n bytes from the underlying body.
79
- # @parameter n [Integer] The number of bytes to read
80
- # @returns [String | Nil] The data read, or `Nil` if the stream ended
82
+ # @parameter n [Integer] The number of bytes to read.
83
+ # @returns [String | Nil] The data read, or `Nil` if the stream ended before reading any bytes.
84
+ # @raises [Error] If the stream ends after reading a partial value.
81
85
  def read_exactly(n)
82
86
  # Fill buffer until we have enough data:
83
87
  while @buffer.bytesize < n
84
- return nil if @body.nil? || @body.empty?
88
+ if @body.nil? || @body.empty?
89
+ return nil if @buffer.empty?
90
+
91
+ raise Error.new(Status::INTERNAL, "Truncated gRPC frame: expected #{n} bytes, received #{@buffer.bytesize}")
92
+ end
85
93
 
86
94
  # Read chunk from underlying body:
87
95
  chunk = @body.read
88
96
 
89
97
  if chunk.nil?
90
- # End of stream:
91
- return nil
98
+ return nil if @buffer.empty?
99
+
100
+ raise Error.new(Status::INTERNAL, "Truncated gRPC frame: expected #{n} bytes, received #{@buffer.bytesize}")
92
101
  end
93
102
 
94
103
  # Append to buffer:
@@ -108,24 +117,20 @@ module Protocol
108
117
  def decompress(data)
109
118
  case @encoding
110
119
  when "gzip"
111
- # Gzip format: zlib stream with gzip header (RFC 1952)
112
- # Use MAX_WBITS + 32 to handle gzip header and CRC
113
- inflater = Zlib::Inflate.new(Zlib::MAX_WBITS + 32)
114
- result = inflater.inflate(data)
115
- inflater.close
116
- result
120
+ begin
121
+ Zlib.gunzip(data)
122
+ rescue => error
123
+ raise Error.new(Status::INTERNAL, "Failed to decompress message: #{error.message}")
124
+ end
117
125
  when "deflate"
118
- # Zlib format (RFC 1950) - default window bits handle zlib header
119
- # This matches HTTP's "deflate" content-encoding
120
- inflater = Zlib::Inflate.new
121
- result = inflater.inflate(data)
122
- inflater.close
123
- result
126
+ begin
127
+ Zlib::Inflate.inflate(data)
128
+ rescue => error
129
+ raise Error.new(Status::INTERNAL, "Failed to decompress message: #{error.message}")
130
+ end
124
131
  else
125
- data
132
+ raise Error.new(Status::UNIMPLEMENTED, "Unsupported compression encoding: #{@encoding.inspect}")
126
133
  end
127
- rescue StandardError => error
128
- raise Error.new(Status::INTERNAL, "Failed to decompress message: #{error.message}")
129
134
  end
130
135
  end
131
136
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "protocol/http"
7
7
  require "protocol/http/body/writable"
8
8
  require "zlib"
9
- require "stringio"
9
+
10
+ require_relative "../error"
11
+ require_relative "../status"
10
12
 
11
13
  module Protocol
12
14
  module GRPC
@@ -86,21 +88,20 @@ module Protocol
86
88
  def compress(data)
87
89
  case @encoding
88
90
  when "gzip"
89
- # Use GzipWriter for proper gzip format (includes headers, CRC)
90
- io = StringIO.new
91
- gz = Zlib::GzipWriter.new(io, @level)
92
- gz.write(data)
93
- gz.close
94
- io.string
91
+ begin
92
+ Zlib.gzip(data, level: @level)
93
+ rescue => error
94
+ raise Error.new(Status::INTERNAL, "Failed to compress message: #{error.message}")
95
+ end
95
96
  when "deflate"
96
- # Use zlib format (RFC 1950) for HTTP compatibility
97
- # This matches HTTP's "deflate" content-encoding
98
- Zlib::Deflate.deflate(data, @level)
97
+ begin
98
+ Zlib::Deflate.deflate(data, @level)
99
+ rescue => error
100
+ raise Error.new(Status::INTERNAL, "Failed to compress message: #{error.message}")
101
+ end
99
102
  else
100
- data # No compression or identity
103
+ raise Error.new(Status::INTERNAL, "Unsupported compression encoding: #{@encoding.inspect}")
101
104
  end
102
- rescue StandardError => error
103
- raise Error.new(Status::INTERNAL, "Failed to compress message: #{error.message}")
104
105
  end
105
106
  end
106
107
  end
@@ -4,12 +4,25 @@
4
4
  # Copyright, 2025, by Samuel Williams.
5
5
 
6
6
  require "async/deadline"
7
- require_relative "methods"
7
+ require_relative "metadata"
8
8
 
9
9
  module Protocol
10
10
  module GRPC
11
11
  # Represents context for a single RPC call.
12
12
  class Call
13
+ # Create a new RPC call context for the given request and response.
14
+ # Automatically computes a deadline from the `grpc-timeout` request header, if present.
15
+ # @parameter request [Protocol::HTTP::Request] The HTTP request
16
+ # @parameter response [Protocol::HTTP::Response | Nil] The HTTP response
17
+ # @returns [Call] The new call context.
18
+ def self.for(request, response = nil)
19
+ if timeout = request.headers["grpc-timeout"]
20
+ deadline = Async::Deadline.start(timeout.to_seconds)
21
+ end
22
+
23
+ return new(request, response, deadline: deadline)
24
+ end
25
+
13
26
  # Initialize a new RPC call context.
14
27
  # @parameter request [Protocol::HTTP::Request] The HTTP request
15
28
  # @parameter response [Protocol::HTTP::Response | Nil] The HTTP response (for setting metadata and trailers)
@@ -33,7 +46,13 @@ module Protocol
33
46
  # Extract metadata from request headers.
34
47
  # @returns [Hash] Custom metadata key-value pairs
35
48
  def metadata
36
- @metadata ||= Methods.extract_metadata(@request.headers)
49
+ @metadata ||= Metadata.extract(@request.headers)
50
+ end
51
+
52
+ # Get the timeout requested by the client.
53
+ # @returns [Numeric | Nil] The original timeout in seconds, or `nil` if no timeout was specified.
54
+ def timeout
55
+ @request.headers["grpc-timeout"]&.to_seconds
37
56
  end
38
57
 
39
58
  # Check if the deadline has expired.
@@ -3,8 +3,6 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
- require_relative "../methods"
7
-
8
6
  module Protocol
9
7
  module GRPC
10
8
  module Header
@@ -14,6 +12,28 @@ module Protocol
14
12
  # The format is: value + unit (H=hours, M=minutes, S=seconds, m=milliseconds, u=microseconds, n=nanoseconds).
15
13
  # This header appears only in request headers, not in trailers.
16
14
  class Timeout < String
15
+ # The wire format for a gRPC timeout value.
16
+ FORMAT = /\A(?<amount>[1-9]\d{0,7})(?<unit>[HMSmun])\z/
17
+
18
+ # Format a timeout duration for the `grpc-timeout` header.
19
+ # @parameter timeout [Numeric] The timeout duration in seconds.
20
+ # @returns [String] The formatted timeout.
21
+ def self.format(timeout)
22
+ if timeout >= 3600
23
+ "#{(timeout / 3600).to_i}H"
24
+ elsif timeout >= 60
25
+ "#{(timeout / 60).to_i}M"
26
+ elsif timeout >= 1
27
+ "#{timeout.to_i}S"
28
+ elsif timeout >= 0.001
29
+ "#{(timeout * 1000).to_i}m"
30
+ elsif timeout >= 0.000001
31
+ "#{(timeout * 1_000_000).to_i}u"
32
+ else
33
+ "#{(timeout * 1_000_000_000).to_i}n"
34
+ end
35
+ end
36
+
17
37
  # Parse a timeout from a header value.
18
38
  #
19
39
  # @parameter value [String] The header value to parse (e.g., "5S", "1000m").
@@ -24,13 +44,13 @@ module Protocol
24
44
 
25
45
  # Coerce a value to a Timeout instance.
26
46
  #
27
- # If a Numeric is provided, it will be formatted as a gRPC timeout string using {Protocol::GRPC::Methods.format_timeout}.
47
+ # If a Numeric is provided, it will be formatted as a gRPC timeout string using {format}.
28
48
  #
29
49
  # @parameter value [String | Numeric] The value to coerce.
30
50
  # @returns [Timeout] A new Timeout instance.
31
51
  def self.coerce(value)
32
52
  if value.is_a?(Numeric)
33
- return new(Protocol::GRPC::Methods.format_timeout(value))
53
+ return new(format(value))
34
54
  else
35
55
  return new(value.to_s)
36
56
  end
@@ -45,9 +65,23 @@ module Protocol
45
65
 
46
66
  # Parse the timeout value to seconds.
47
67
  #
48
- # @returns [Numeric | Nil] Timeout in seconds, or `Nil` if value is invalid.
68
+ # @returns [Numeric] Timeout in seconds.
69
+ # @raises [ArgumentError] If the timeout value is invalid.
49
70
  def to_seconds
50
- Protocol::GRPC::Methods.parse_timeout(self)
71
+ unless match = FORMAT.match(self)
72
+ raise ArgumentError, "Invalid grpc-timeout: #{self.inspect}"
73
+ end
74
+
75
+ amount = match[:amount].to_i
76
+
77
+ case match[:unit]
78
+ when "H" then amount * 3600
79
+ when "M" then amount * 60
80
+ when "S" then amount
81
+ when "m" then amount / 1000.0
82
+ when "u" then amount / 1_000_000.0
83
+ when "n" then amount / 1_000_000_000.0
84
+ end
51
85
  end
52
86
 
53
87
  # Merge another timeout value (takes the new value, as timeout should only appear once)
@@ -3,7 +3,7 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
- require_relative "methods"
6
+ require_relative "route"
7
7
 
8
8
  module Protocol
9
9
  module GRPC
@@ -25,6 +25,12 @@ module Protocol
25
25
  class Interface
26
26
  # RPC method definition
27
27
  RPC = Struct.new(:name, :request_class, :response_class, :streaming, :method, keyword_init: true) do
28
+ # Initialize a new RPC method definition.
29
+ # @parameter name [Symbol] The RPC method name.
30
+ # @parameter request_class [Class | Streaming | Nil] The request message class.
31
+ # @parameter response_class [Class | Streaming | Nil] The response message class.
32
+ # @parameter streaming [Symbol] The streaming mode.
33
+ # @parameter method [Symbol | Nil] The Ruby method name.
28
34
  def initialize(name:, request_class:, response_class:, streaming: :unary, method: nil)
29
35
  super
30
36
  end
@@ -153,7 +159,7 @@ module Protocol
153
159
  # @parameter method_name [String | Symbol] Method name in PascalCase (e.g., :SayHello)
154
160
  # @returns [String] gRPC path with PascalCase method name
155
161
  def path(method_name)
156
- Methods.build_path(@name, method_name.to_s)
162
+ Route.build(@name, method_name.to_s)
157
163
  end
158
164
 
159
165
  private
@@ -1,18 +1,70 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
+
6
+ require "base64"
5
7
 
6
- require "uri"
7
8
  require_relative "header"
8
9
  require_relative "status"
9
10
 
10
11
  module Protocol
11
12
  module GRPC
12
- # @namespace
13
+ # Provides operations for building and extracting gRPC metadata.
13
14
  module Metadata
15
+ # Build gRPC request headers containing the given metadata.
16
+ # @parameter metadata [Hash] Custom metadata key-value pairs.
17
+ # @parameter timeout [Numeric | Nil] Optional timeout in seconds.
18
+ # @parameter content_type [String] The request content type.
19
+ # @returns [Protocol::HTTP::Headers] The constructed request headers.
20
+ def self.build(metadata: {}, timeout: nil, content_type: "application/grpc+proto")
21
+ headers = Protocol::HTTP::Headers.new(policy: Protocol::GRPC::HEADER_POLICY)
22
+ headers["content-type"] = content_type
23
+ headers["te"] = "trailers"
24
+
25
+ if timeout
26
+ # Coerced to proper format by header policy:
27
+ headers["grpc-timeout"] = timeout
28
+ end
29
+
30
+ metadata.each do |key, value|
31
+ # Binary headers end with -bin and are base64 encoded:
32
+ headers[key] = if key.end_with?("-bin")
33
+ Base64.strict_encode64(value)
34
+ else
35
+ value.to_s
36
+ end
37
+ end
38
+
39
+ headers
40
+ end
41
+
42
+ # Extract application metadata from gRPC headers.
43
+ # @parameter headers [Protocol::HTTP::Headers] The headers to inspect.
44
+ # @returns [Hash] The extracted metadata key-value pairs.
45
+ def self.extract(headers)
46
+ metadata = {}
47
+
48
+ headers.to_h.each do |key, value|
49
+ # Skip reserved headers:
50
+ next if key.start_with?("grpc-") || key == "content-type" || key == "te"
51
+
52
+ # Decode binary headers:
53
+ if key.end_with?("-bin")
54
+ if value.is_a?(String)
55
+ value = Base64.strict_decode64(value)
56
+ elsif value.is_a?(Array)
57
+ value = value.map{|item| Base64.strict_decode64(item)}
58
+ end
59
+ end
60
+
61
+ metadata[key] = value
62
+ end
63
+
64
+ metadata
65
+ end
66
+
14
67
  # Extract gRPC status from headers.
15
- # Convenience method that handles both Header::Status instances and raw values.
16
68
  # Returns Status::UNKNOWN if status is not present.
17
69
  #
18
70
  # Note: In Protocol::HTTP::Headers, trailers are merged into the headers
@@ -29,31 +81,10 @@ module Protocol
29
81
  status = headers["grpc-status"]
30
82
  return Status::UNKNOWN unless status
31
83
 
32
- if status.is_a?(Header::Status)
33
- status.to_i
34
- else
35
- # Fallback for when header policy isn't used
36
- # Handle Array case (may occur with external clients)
37
- status_value = if status.is_a?(Array)
38
- # Flatten and take first non-nil value, recursively handle nested arrays
39
- flattened = status.flatten.compact.first
40
- # If still an array, take first element
41
- flattened.is_a?(Array) ? flattened.first : flattened
42
- else
43
- status
44
- end
45
-
46
- # Convert to string then integer to handle various types
47
- # Handle case where status_value might still be an array somehow
48
- if status_value.is_a?(Array)
49
- status_value = status_value.first
50
- end
51
- status_value.to_s.to_i
52
- end
84
+ return status.to_i
53
85
  end
54
86
 
55
87
  # Extract gRPC status message from headers.
56
- # Convenience method that handles both Header::Message instances and raw values.
57
88
  # Returns `Nil` if message is not present.
58
89
  #
59
90
  # @parameter headers [Protocol::HTTP::Headers]
@@ -66,13 +97,7 @@ module Protocol
66
97
  message = headers["grpc-message"]
67
98
  return nil unless message
68
99
 
69
- if message.is_a?(Header::Message)
70
- message.decode
71
- else
72
- # Fallback for when header policy isn't used
73
- message_value = message.is_a?(Array) ? message.first : message.to_s
74
- URI.decode_www_form_component(message_value)
75
- end
100
+ return message.decode
76
101
  end
77
102
 
78
103
  # Assign gRPC status, message, and optional backtrace to headers.
@@ -3,8 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
- require "base64"
7
- require "protocol/http"
6
+ require_relative "metadata"
7
+ require_relative "route"
8
8
 
9
9
  module Protocol
10
10
  module GRPC
@@ -14,16 +14,21 @@ module Protocol
14
14
  # @parameter service [String] e.g., "my_service.Greeter"
15
15
  # @parameter method [String] e.g., "SayHello"
16
16
  # @returns [String] e.g., "/my_service.Greeter/SayHello"
17
+ # @deprecated Use {Route.build} instead.
17
18
  def self.build_path(service, method)
18
- "/#{service}/#{method}"
19
+ Kernel.warn("`Protocol::GRPC::Methods.build_path` is deprecated; use `Protocol::GRPC::Route.build` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
20
+
21
+ Route.build(service, method)
19
22
  end
20
23
 
21
24
  # Parse service and method from gRPC path.
22
25
  # @parameter path [String] e.g., "/my_service.Greeter/SayHello"
23
26
  # @returns [Array(String | String)] [service, method]
27
+ # @deprecated Use {Route.parse} instead.
24
28
  def self.parse_path(path)
25
- parts = path.split("/")
26
- [parts[1], parts[2]]
29
+ Kernel.warn("`Protocol::GRPC::Methods.parse_path` is deprecated; use `Protocol::GRPC::Route.parse` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
30
+
31
+ Route.parse(path)
27
32
  end
28
33
 
29
34
  # Build gRPC request headers.
@@ -31,92 +36,45 @@ module Protocol
31
36
  # @parameter timeout [Numeric | Nil] Optional timeout in seconds
32
37
  # @parameter content_type [String] Content type (default: "application/grpc+proto")
33
38
  # @returns [Protocol::HTTP::Headers]
39
+ # @deprecated Use {Metadata.build} instead.
34
40
  def self.build_headers(metadata: {}, timeout: nil, content_type: "application/grpc+proto")
35
- headers = Protocol::HTTP::Headers.new(policy: Protocol::GRPC::HEADER_POLICY)
36
- headers["content-type"] = content_type
37
- headers["te"] = "trailers"
38
-
39
- if timeout
40
- # Coerced to proper format by header policy:
41
- headers["grpc-timeout"] = timeout
42
- end
43
-
44
- metadata.each do |key, value|
45
- # Binary headers end with -bin and are base64 encoded:
46
- headers[key] = if key.end_with?("-bin")
47
- Base64.strict_encode64(value)
48
- else
49
- value.to_s
50
- end
51
- end
41
+ Kernel.warn("`Protocol::GRPC::Methods.build_headers` is deprecated; use `Protocol::GRPC::Metadata.build` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
52
42
 
53
- headers
43
+ Metadata.build(metadata: metadata, timeout: timeout, content_type: content_type)
54
44
  end
55
45
 
56
46
  # Extract metadata from gRPC headers.
57
47
  # @parameter headers [Protocol::HTTP::Headers]
58
48
  # @returns [Hash] Metadata key-value pairs
49
+ # @deprecated Use {Metadata.extract} instead.
59
50
  def self.extract_metadata(headers)
60
- metadata = {}
51
+ Kernel.warn("`Protocol::GRPC::Methods.extract_metadata` is deprecated; use `Protocol::GRPC::Metadata.extract` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
61
52
 
62
- headers.to_h.each do |key, value|
63
- # Skip reserved headers:
64
- next if key.start_with?("grpc-") || key == "content-type" || key == "te"
65
-
66
- # Decode binary headers:
67
- if key.end_with?("-bin")
68
- if value.is_a?(String)
69
- value = Base64.strict_decode64(value)
70
- elsif value.is_a?(Array)
71
- value = value.map{|item| Base64.strict_decode64(item)}
72
- end
73
- else
74
- value
75
- end
76
-
77
- metadata[key] = value
78
- end
79
-
80
- metadata
53
+ Metadata.extract(headers)
81
54
  end
82
55
 
83
56
  # Format timeout for grpc-timeout header.
84
57
  # @parameter timeout [Numeric] Timeout in seconds
85
58
  # @returns [String] e.g., "1000m" for 1 second
59
+ # @deprecated Use {Protocol::GRPC::Header::Timeout.format} instead.
86
60
  def self.format_timeout(timeout)
87
- # gRPC timeout format: value + unit (H=hours, M=minutes, S=seconds, m=milliseconds, u=microseconds, n=nanoseconds)
88
- if timeout >= 3600
89
- "#{(timeout / 3600).to_i}H"
90
- elsif timeout >= 60
91
- "#{(timeout / 60).to_i}M"
92
- elsif timeout >= 1
93
- "#{timeout.to_i}S"
94
- elsif timeout >= 0.001
95
- "#{(timeout * 1000).to_i}m"
96
- elsif timeout >= 0.000001
97
- "#{(timeout * 1_000_000).to_i}u"
98
- else
99
- "#{(timeout * 1_000_000_000).to_i}n"
100
- end
61
+ Kernel.warn("`Protocol::GRPC::Methods.format_timeout` is deprecated; use `Protocol::GRPC::Header::Timeout.format` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
62
+
63
+ Header::Timeout.format(timeout)
101
64
  end
102
65
 
103
66
  # Parse grpc-timeout header value.
104
67
  # @parameter value [String] e.g., "1000m"
105
68
  # @returns [Numeric | Nil] Timeout in seconds, or `Nil` if value is invalid
69
+ # @deprecated Use {Protocol::GRPC::Header::Timeout#to_seconds} instead.
106
70
  def self.parse_timeout(value)
107
- return nil unless value
71
+ Kernel.warn("`Protocol::GRPC::Methods.parse_timeout` is deprecated; use `Protocol::GRPC::Header::Timeout#to_seconds` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
108
72
 
109
- amount = value[0...-1].to_i
110
- unit = value[-1]
73
+ return nil unless value
111
74
 
112
- case unit
113
- when "H" then amount * 3600
114
- when "M" then amount * 60
115
- when "S" then amount
116
- when "m" then amount / 1000.0
117
- when "u" then amount / 1_000_000.0
118
- when "n" then amount / 1_000_000_000.0
119
- end
75
+ Header::Timeout.parse(value).to_seconds
76
+ rescue ArgumentError
77
+ return nil
120
78
  end
121
79
  end
122
80
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Released under the MIT License.
4
- # Copyright, 2025, by Samuel Williams.
4
+ # Copyright, 2025-2026, by Samuel Williams.
5
5
 
6
6
  require "protocol/http"
7
7
  require_relative "error"
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module GRPC
8
+ # Provides operations for parsing and building gRPC request paths.
9
+ module Route
10
+ IDENTIFIER_PATTERN = "[A-Za-z][A-Za-z0-9_]*"
11
+ SERVICE_PATTERN = /\A#{IDENTIFIER_PATTERN}(?:\.#{IDENTIFIER_PATTERN})*\z/
12
+ METHOD_PATTERN = /\A#{IDENTIFIER_PATTERN}\z/
13
+ PATTERN = %r{\A/(#{IDENTIFIER_PATTERN}(?:\.#{IDENTIFIER_PATTERN})*)/(#{IDENTIFIER_PATTERN})\z}
14
+ private_constant :IDENTIFIER_PATTERN, :SERVICE_PATTERN, :METHOD_PATTERN, :PATTERN
15
+
16
+ # Parse a gRPC request path into its service and method names.
17
+ # @parameter path [String] The gRPC request path.
18
+ # @returns [Array(String)] The service and method names.
19
+ # @raises [ArgumentError] If the path does not contain valid protobuf service and method names.
20
+ def self.parse(path)
21
+ match = PATTERN.match(path) if path.is_a?(String)
22
+
23
+ unless match
24
+ raise ArgumentError, "Invalid gRPC route: #{path.inspect}"
25
+ end
26
+
27
+ [match[1], match[2]]
28
+ end
29
+
30
+ # Build a gRPC request path from its service and method names.
31
+ # @parameter service_name [String] The fully qualified service name.
32
+ # @parameter method_name [String] The method name.
33
+ # @returns [String] The gRPC request path.
34
+ # @raises [ArgumentError] If either component is not a valid protobuf service or method name.
35
+ def self.build(service_name, method_name)
36
+ unless service_name.is_a?(String) && SERVICE_PATTERN.match?(service_name)
37
+ raise ArgumentError, "Invalid gRPC service name: #{service_name.inspect}"
38
+ end
39
+
40
+ unless method_name.is_a?(String) && METHOD_PATTERN.match?(method_name)
41
+ raise ArgumentError, "Invalid gRPC method name: #{method_name.inspect}"
42
+ end
43
+
44
+ "/#{service_name}/#{method_name}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -7,7 +7,7 @@
7
7
  module Protocol
8
8
  # @namespace
9
9
  module GRPC
10
- VERSION = "0.11.0"
10
+ VERSION = "0.13.0"
11
11
  end
12
12
  end
13
13
 
data/lib/protocol/grpc.rb CHANGED
@@ -7,9 +7,10 @@ require_relative "grpc/version"
7
7
 
8
8
  require_relative "grpc/status"
9
9
  require_relative "grpc/error"
10
- require_relative "grpc/methods"
10
+ require_relative "grpc/route"
11
11
  require_relative "grpc/header"
12
12
  require_relative "grpc/metadata"
13
+ require_relative "grpc/methods"
13
14
  require_relative "grpc/call"
14
15
  require_relative "grpc/body/readable"
15
16
  require_relative "grpc/body/writable"
data/readme.md CHANGED
@@ -28,6 +28,16 @@ Please see the [project documentation](https://socketry.github.io/protocol-grpc/
28
28
 
29
29
  Please see the [project releases](https://socketry.github.io/protocol-grpc/releases/index) for all releases.
30
30
 
31
+ ### v0.13.0
32
+
33
+ - Added `Protocol::GRPC::Route` for building and parsing gRPC request paths.
34
+ - Added `Protocol::GRPC::Metadata.build` and `Protocol::GRPC::Metadata.extract`, replacing the corresponding deprecated `Protocol::GRPC::Methods` helpers.
35
+
36
+ ### v0.12.0
37
+
38
+ - Added `Protocol::GRPC::Call#timeout` to expose the client-supplied gRPC timeout in seconds.
39
+ - Added `Protocol::GRPC::Header::Timeout.format` and deprecated `Protocol::GRPC::Methods.format_timeout` and `Protocol::GRPC::Methods.parse_timeout`.
40
+
31
41
  ### v0.11.0
32
42
 
33
43
  - Rename `add_status!` to `assign_status!` to better reflect its purpose of assigning status information to headers or trailers.
@@ -66,11 +76,27 @@ Please see the [project releases](https://socketry.github.io/protocol-grpc/relea
66
76
 
67
77
  We welcome contributions to this project.
68
78
 
69
- 1. Fork it.
79
+ 1. Fork the repository.
70
80
  2. Create your feature branch (`git checkout -b my-new-feature`).
71
- 3. Commit your changes (`git commit -am 'Add some feature'`).
81
+ 3. Commit your changes (`git commit -am 'Add some feature.'`).
72
82
  4. Push to the branch (`git push origin my-new-feature`).
73
- 5. Create new Pull Request.
83
+ 5. Create a new pull request.
84
+
85
+ ### Running Tests
86
+
87
+ To run the test suite:
88
+
89
+ ``` shell
90
+ bundle exec sus
91
+ ```
92
+
93
+ ### Making Releases
94
+
95
+ To make a new release:
96
+
97
+ ``` shell
98
+ bundle exec bake gem:release:patch # or minor or major
99
+ ```
74
100
 
75
101
  ### Developer Certificate of Origin
76
102
 
data/releases.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Releases
2
2
 
3
+ ## v0.13.0
4
+
5
+ - Added `Protocol::GRPC::Route` for building and parsing gRPC request paths.
6
+ - Added `Protocol::GRPC::Metadata.build` and `Protocol::GRPC::Metadata.extract`, replacing the corresponding deprecated `Protocol::GRPC::Methods` helpers.
7
+
8
+ ## v0.12.0
9
+
10
+ - Added `Protocol::GRPC::Call#timeout` to expose the client-supplied gRPC timeout in seconds.
11
+ - Added `Protocol::GRPC::Header::Timeout.format` and deprecated `Protocol::GRPC::Methods.format_timeout` and `Protocol::GRPC::Methods.parse_timeout`.
12
+
3
13
  ## v0.11.0
4
14
 
5
15
  - Rename `add_status!` to `assign_status!` to better reflect its purpose of assigning status information to headers or trailers.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: protocol-grpc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -118,6 +118,7 @@ files:
118
118
  - lib/protocol/grpc/metadata.rb
119
119
  - lib/protocol/grpc/methods.rb
120
120
  - lib/protocol/grpc/middleware.rb
121
+ - lib/protocol/grpc/route.rb
121
122
  - lib/protocol/grpc/status.rb
122
123
  - lib/protocol/grpc/version.rb
123
124
  - license.md
@@ -127,6 +128,8 @@ homepage: https://github.com/socketry/protocol-grpc
127
128
  licenses:
128
129
  - MIT
129
130
  metadata:
131
+ bug_tracker_uri: https://github.com/socketry/protocol-grpc/issues
132
+ changelog_uri: https://github.com/socketry/protocol-grpc/blob/main/releases.md
130
133
  documentation_uri: https://socketry.github.io/protocol-grpc/
131
134
  source_code_uri: https://github.com/socketry/protocol-grpc.git
132
135
  rdoc_options: []
@@ -136,14 +139,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
136
139
  requirements:
137
140
  - - ">="
138
141
  - !ruby/object:Gem::Version
139
- version: '3.2'
142
+ version: '3.3'
140
143
  required_rubygems_version: !ruby/object:Gem::Requirement
141
144
  requirements:
142
145
  - - ">="
143
146
  - !ruby/object:Gem::Version
144
147
  version: '0'
145
148
  requirements: []
146
- rubygems_version: 4.0.3
149
+ rubygems_version: 4.0.10
147
150
  specification_version: 4
148
151
  summary: Protocol abstractions for gRPC, built on top of protocol-http.
149
152
  test_files: []
metadata.gz.sig CHANGED
Binary file