protocol-grpc 0.11.0 → 0.12.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: 82a5d69076bd57b3e2fe9c3e8d46f2caf79874136d4577664240179aa39f0966
4
+ data.tar.gz: 1b46c0bde1016ebc61998c76592f64b97714b9b701d264c5a2dd4c64433b1745
5
5
  SHA512:
6
- metadata.gz: c41ddca4368db85c9d18d9c327147104f2a8c62193b9e4488ff90d662a94bc37bb875bb43e19285b093d5554377bc28fd6c4595e447684a984bbc7cb480d841e
7
- data.tar.gz: 67935af35d49bc8227280862b82c1823e43fc065487d59fa19759e293d7d6c3569b9955b4c621ae16b202dbb17288fbc2680ff9a7ea0659c28a1f35208bdd82b
6
+ metadata.gz: c7aa9a55156f31361fe0c8afc57f74b3c69760e39c14985ec999250c507b8f9cad650ac70435dda2b7620eb3557d6105126a6a5fb2ad045b5fb0f306f1aa572d
7
+ data.tar.gz: efe791eece6660faced232e8d1d9fc0a207ee5866b9905fa93cc6b78c1781c4d9f7c804d5083df76dffcc65b6798cd7e4f3fb07d185987ef8a99a7247f688b5f
checksums.yaml.gz.sig CHANGED
Binary file
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:
@@ -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
@@ -10,6 +10,19 @@ 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)
@@ -36,6 +49,12 @@ module Protocol
36
49
  @metadata ||= Methods.extract_metadata(@request.headers)
37
50
  end
38
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
56
+ end
57
+
39
58
  # Check if the deadline has expired.
40
59
  # @returns [Boolean] `true` if the deadline has expired, `false` otherwise
41
60
  def deadline_exceeded?
@@ -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)
@@ -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
@@ -1,9 +1,8 @@
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
- require "uri"
7
6
  require_relative "header"
8
7
  require_relative "status"
9
8
 
@@ -12,7 +11,6 @@ module Protocol
12
11
  # @namespace
13
12
  module Metadata
14
13
  # Extract gRPC status from headers.
15
- # Convenience method that handles both Header::Status instances and raw values.
16
14
  # Returns Status::UNKNOWN if status is not present.
17
15
  #
18
16
  # Note: In Protocol::HTTP::Headers, trailers are merged into the headers
@@ -29,31 +27,10 @@ module Protocol
29
27
  status = headers["grpc-status"]
30
28
  return Status::UNKNOWN unless status
31
29
 
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
30
+ return status.to_i
53
31
  end
54
32
 
55
33
  # Extract gRPC status message from headers.
56
- # Convenience method that handles both Header::Message instances and raw values.
57
34
  # Returns `Nil` if message is not present.
58
35
  #
59
36
  # @parameter headers [Protocol::HTTP::Headers]
@@ -66,13 +43,7 @@ module Protocol
66
43
  message = headers["grpc-message"]
67
44
  return nil unless message
68
45
 
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
46
+ return message.decode
76
47
  end
77
48
 
78
49
  # Assign gRPC status, message, and optional backtrace to headers.
@@ -6,6 +6,8 @@
6
6
  require "base64"
7
7
  require "protocol/http"
8
8
 
9
+ require_relative "header/timeout"
10
+
9
11
  module Protocol
10
12
  module GRPC
11
13
  # Provides utility methods for building and parsing gRPC-compatible HTTP requests.
@@ -83,40 +85,25 @@ module Protocol
83
85
  # Format timeout for grpc-timeout header.
84
86
  # @parameter timeout [Numeric] Timeout in seconds
85
87
  # @returns [String] e.g., "1000m" for 1 second
88
+ # @deprecated Use {Protocol::GRPC::Header::Timeout.format} instead.
86
89
  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
90
+ Kernel.warn("`Protocol::GRPC::Methods.format_timeout` is deprecated; use `Protocol::GRPC::Header::Timeout.format` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
91
+
92
+ Header::Timeout.format(timeout)
101
93
  end
102
94
 
103
95
  # Parse grpc-timeout header value.
104
96
  # @parameter value [String] e.g., "1000m"
105
97
  # @returns [Numeric | Nil] Timeout in seconds, or `Nil` if value is invalid
98
+ # @deprecated Use {Protocol::GRPC::Header::Timeout#to_seconds} instead.
106
99
  def self.parse_timeout(value)
107
- return nil unless value
100
+ Kernel.warn("`Protocol::GRPC::Methods.parse_timeout` is deprecated; use `Protocol::GRPC::Header::Timeout#to_seconds` instead.", uplevel: 1, category: :deprecated) if $VERBOSE
108
101
 
109
- amount = value[0...-1].to_i
110
- unit = value[-1]
102
+ return nil unless value
111
103
 
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
104
+ Header::Timeout.parse(value).to_seconds
105
+ rescue ArgumentError
106
+ return nil
120
107
  end
121
108
  end
122
109
  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"
@@ -7,7 +7,7 @@
7
7
  module Protocol
8
8
  # @namespace
9
9
  module GRPC
10
- VERSION = "0.11.0"
10
+ VERSION = "0.12.0"
11
11
  end
12
12
  end
13
13
 
data/readme.md CHANGED
@@ -28,6 +28,11 @@ 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.12.0
32
+
33
+ - Added `Protocol::GRPC::Call#timeout` to expose the client-supplied gRPC timeout in seconds.
34
+ - Added `Protocol::GRPC::Header::Timeout.format` and deprecated `Protocol::GRPC::Methods.format_timeout` and `Protocol::GRPC::Methods.parse_timeout`.
35
+
31
36
  ### v0.11.0
32
37
 
33
38
  - Rename `add_status!` to `assign_status!` to better reflect its purpose of assigning status information to headers or trailers.
@@ -66,11 +71,27 @@ Please see the [project releases](https://socketry.github.io/protocol-grpc/relea
66
71
 
67
72
  We welcome contributions to this project.
68
73
 
69
- 1. Fork it.
74
+ 1. Fork the repository.
70
75
  2. Create your feature branch (`git checkout -b my-new-feature`).
71
- 3. Commit your changes (`git commit -am 'Add some feature'`).
76
+ 3. Commit your changes (`git commit -am 'Add some feature.'`).
72
77
  4. Push to the branch (`git push origin my-new-feature`).
73
- 5. Create new Pull Request.
78
+ 5. Create a new pull request.
79
+
80
+ ### Running Tests
81
+
82
+ To run the test suite:
83
+
84
+ ``` shell
85
+ bundle exec sus
86
+ ```
87
+
88
+ ### Making Releases
89
+
90
+ To make a new release:
91
+
92
+ ``` shell
93
+ bundle exec bake gem:release:patch # or minor or major
94
+ ```
74
95
 
75
96
  ### Developer Certificate of Origin
76
97
 
data/releases.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Releases
2
2
 
3
+ ## v0.12.0
4
+
5
+ - Added `Protocol::GRPC::Call#timeout` to expose the client-supplied gRPC timeout in seconds.
6
+ - Added `Protocol::GRPC::Header::Timeout.format` and deprecated `Protocol::GRPC::Methods.format_timeout` and `Protocol::GRPC::Methods.parse_timeout`.
7
+
3
8
  ## v0.11.0
4
9
 
5
10
  - 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.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
@@ -127,6 +127,8 @@ homepage: https://github.com/socketry/protocol-grpc
127
127
  licenses:
128
128
  - MIT
129
129
  metadata:
130
+ bug_tracker_uri: https://github.com/socketry/protocol-grpc/issues
131
+ changelog_uri: https://github.com/socketry/protocol-grpc/blob/main/releases.md
130
132
  documentation_uri: https://socketry.github.io/protocol-grpc/
131
133
  source_code_uri: https://github.com/socketry/protocol-grpc.git
132
134
  rdoc_options: []
@@ -136,14 +138,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
136
138
  requirements:
137
139
  - - ">="
138
140
  - !ruby/object:Gem::Version
139
- version: '3.2'
141
+ version: '3.3'
140
142
  required_rubygems_version: !ruby/object:Gem::Requirement
141
143
  requirements:
142
144
  - - ">="
143
145
  - !ruby/object:Gem::Version
144
146
  version: '0'
145
147
  requirements: []
146
- rubygems_version: 4.0.3
148
+ rubygems_version: 4.0.10
147
149
  specification_version: 4
148
150
  summary: Protocol abstractions for gRPC, built on top of protocol-http.
149
151
  test_files: []
metadata.gz.sig CHANGED
Binary file