protocol-grpc 0.5.1 → 0.7.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.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require "protocol/http"
7
+ require "protocol/http/body/wrapper"
8
+ require "zlib"
9
+
10
+ module Protocol
11
+ module GRPC
12
+ # @namespace
13
+ module Body
14
+ # Represents a readable body for gRPC messages with length-prefixed framing.
15
+ # This is the standard readable body for gRPC - all gRPC responses use message framing.
16
+ # Wraps the underlying HTTP body and transforms raw chunks into decoded gRPC messages.
17
+ class Readable < Protocol::HTTP::Body::Wrapper
18
+ # Wrap the body of a message.
19
+ #
20
+ # @parameter message [Request | Response] The message to wrap.
21
+ # @parameter options [Hash] The options to pass to the initializer.
22
+ # @returns [Readable | Nil] The wrapped body or `nil` if the message has no body.
23
+ def self.wrap(message, **options)
24
+ if body = message.body
25
+ message.body = self.new(body, **options)
26
+ end
27
+
28
+ return message.body
29
+ end
30
+
31
+ # Initialize a new readable body for gRPC messages.
32
+ # @parameter body [Protocol::HTTP::Body::Readable] The underlying HTTP body
33
+ # @parameter message_class [Class | Nil] Protobuf message class with .decode method.
34
+ # If `nil`, returns raw binary data (useful for channel adapters)
35
+ # @parameter encoding [String | Nil] Compression encoding (from grpc-encoding header)
36
+ def initialize(body, message_class: nil, encoding: nil)
37
+ super(body)
38
+ @message_class = message_class
39
+ @encoding = encoding
40
+ @buffer = String.new.force_encoding(Encoding::BINARY)
41
+ end
42
+
43
+ # @attribute [String | Nil] The compression encoding.
44
+ attr_reader :encoding
45
+
46
+ # Read the next gRPC message.
47
+ # Overrides Wrapper#read to transform raw HTTP body chunks into decoded gRPC messages.
48
+ # @returns [Object | String | Nil] Decoded message, raw binary, or `Nil` if stream ended
49
+ def read
50
+ return nil if @body.nil? || @body.empty?
51
+
52
+ # Read 5-byte prefix: 1 byte compression flag + 4 bytes length
53
+ prefix = read_exactly(5)
54
+ return nil unless prefix
55
+
56
+ compressed = prefix[0].unpack1("C") == 1
57
+ length = prefix[1..4].unpack1("N")
58
+
59
+ # Read the message body:
60
+ data = read_exactly(length)
61
+ return nil unless data
62
+
63
+ # Decompress if needed:
64
+ data = decompress(data) if compressed
65
+
66
+ # Decode using message class if provided, otherwise return binary:
67
+ # This allows binary mode for channel adapters
68
+ if @message_class
69
+ # Use protobuf gem's decode method:
70
+ @message_class.decode(data)
71
+ else
72
+ data # Return raw binary
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ # 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
81
+ def read_exactly(n)
82
+ # Fill buffer until we have enough data:
83
+ while @buffer.bytesize < n
84
+ return nil if @body.nil? || @body.empty?
85
+
86
+ # Read chunk from underlying body:
87
+ chunk = @body.read
88
+
89
+ if chunk.nil?
90
+ # End of stream:
91
+ return nil
92
+ end
93
+
94
+ # Append to buffer:
95
+ @buffer << chunk.force_encoding(Encoding::BINARY)
96
+ end
97
+
98
+ # Extract the required data:
99
+ data = @buffer[0...n]
100
+ @buffer = @buffer[n..]
101
+ data
102
+ end
103
+
104
+ # Decompress data using the configured encoding.
105
+ # @parameter data [String] The compressed data
106
+ # @returns [String] The decompressed data
107
+ # @raises [Error] If decompression fails
108
+ def decompress(data)
109
+ case @encoding
110
+ when "gzip"
111
+ Zlib::Gunzip.new.inflate(data)
112
+ when "deflate"
113
+ Zlib::Inflate.inflate(data)
114
+ else
115
+ data
116
+ end
117
+ rescue StandardError => error
118
+ raise Error.new(Status::INTERNAL, "Failed to decompress message: #{error.message}")
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -3,121 +3,20 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2025, by Samuel Williams.
5
5
 
6
- require "protocol/http"
7
- require "protocol/http/body/wrapper"
8
- require "zlib"
6
+ # Compatibility shim for the old file name.
7
+ # This file is deprecated and will be removed in a future version.
8
+ # Please update your code to require 'protocol/grpc/body/readable' instead.
9
+
10
+ warn "Requiring 'protocol/grpc/body/readable_body' is deprecated. Please require 'protocol/grpc/body/readable' instead.", uplevel: 1 if $VERBOSE
11
+
12
+ require_relative "readable"
9
13
 
10
14
  module Protocol
11
15
  module GRPC
12
- # @namespace
13
16
  module Body
14
- # Represents a readable body for gRPC messages with length-prefixed framing.
15
- # This is the standard readable body for gRPC - all gRPC responses use message framing.
16
- # Wraps the underlying HTTP body and transforms raw chunks into decoded gRPC messages.
17
- class ReadableBody < Protocol::HTTP::Body::Wrapper
18
- # Wrap the body of a message.
19
- #
20
- # @parameter message [Request | Response] The message to wrap.
21
- # @parameter options [Hash] The options to pass to the initializer.
22
- # @returns [ReadableBody | Nil] The wrapped body or `nil` if the message has no body.
23
- def self.wrap(message, **options)
24
- if body = message.body
25
- message.body = self.new(body, **options)
26
- end
27
-
28
- return message.body
29
- end
30
-
31
- # Initialize a new readable body for gRPC messages.
32
- # @parameter body [Protocol::HTTP::Body::Readable] The underlying HTTP body
33
- # @parameter message_class [Class | Nil] Protobuf message class with .decode method.
34
- # If `nil`, returns raw binary data (useful for channel adapters)
35
- # @parameter encoding [String | Nil] Compression encoding (from grpc-encoding header)
36
- def initialize(body, message_class: nil, encoding: nil)
37
- super(body)
38
- @message_class = message_class
39
- @encoding = encoding
40
- @buffer = String.new.force_encoding(Encoding::BINARY)
41
- end
42
-
43
- # @attribute [String | Nil] The compression encoding.
44
- attr_reader :encoding
45
-
46
- # Read the next gRPC message.
47
- # Overrides Wrapper#read to transform raw HTTP body chunks into decoded gRPC messages.
48
- # @returns [Object | String | Nil] Decoded message, raw binary, or `Nil` if stream ended
49
- def read
50
- return nil if @body.nil? || @body.empty?
51
-
52
- # Read 5-byte prefix: 1 byte compression flag + 4 bytes length
53
- prefix = read_exactly(5)
54
- return nil unless prefix
55
-
56
- compressed = prefix[0].unpack1("C") == 1
57
- length = prefix[1..4].unpack1("N")
58
-
59
- # Read the message body:
60
- data = read_exactly(length)
61
- return nil unless data
62
-
63
- # Decompress if needed:
64
- data = decompress(data) if compressed
65
-
66
- # Decode using message class if provided, otherwise return binary:
67
- # This allows binary mode for channel adapters
68
- if @message_class
69
- # Use protobuf gem's decode method:
70
- @message_class.decode(data)
71
- else
72
- data # Return raw binary
73
- end
74
- end
75
-
76
- private
77
-
78
- # 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
81
- def read_exactly(n)
82
- # Fill buffer until we have enough data:
83
- while @buffer.bytesize < n
84
- return nil if @body.nil? || @body.empty?
85
-
86
- # Read chunk from underlying body:
87
- chunk = @body.read
88
-
89
- if chunk.nil?
90
- # End of stream:
91
- return nil
92
- end
93
-
94
- # Append to buffer:
95
- @buffer << chunk.force_encoding(Encoding::BINARY)
96
- end
97
-
98
- # Extract the required data:
99
- data = @buffer[0...n]
100
- @buffer = @buffer[n..]
101
- data
102
- end
103
-
104
- # Decompress data using the configured encoding.
105
- # @parameter data [String] The compressed data
106
- # @returns [String] The decompressed data
107
- # @raises [Error] If decompression fails
108
- def decompress(data)
109
- case @encoding
110
- when "gzip"
111
- Zlib::Gunzip.new.inflate(data)
112
- when "deflate"
113
- Zlib::Inflate.inflate(data)
114
- else
115
- data
116
- end
117
- rescue StandardError => error
118
- raise Error.new(Status::INTERNAL, "Failed to decompress message: #{error.message}")
119
- end
120
- end
17
+ # Compatibility alias for the old class name.
18
+ # @deprecated Use {Readable} instead.
19
+ ReadableBody = Readable
121
20
  end
122
21
  end
123
22
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require "protocol/http"
7
+ require "protocol/http/body/writable"
8
+ require "zlib"
9
+ require "stringio"
10
+
11
+ module Protocol
12
+ module GRPC
13
+ # @namespace
14
+ module Body
15
+ # Represents a writable body for gRPC messages with length-prefixed framing.
16
+ # This is the standard writable body for gRPC - all gRPC requests use message framing.
17
+ class Writable < Protocol::HTTP::Body::Writable
18
+ # Initialize a new writable body for gRPC messages.
19
+ # @parameter encoding [String | Nil] Compression encoding (gzip, deflate, identity)
20
+ # @parameter level [Integer] Compression level if encoding is used
21
+ # @parameter message_class [Class | Nil] Expected message class for validation
22
+ def initialize(encoding: nil, level: Zlib::DEFAULT_COMPRESSION, message_class: nil, **options)
23
+ super(**options)
24
+ @encoding = encoding
25
+ @level = level
26
+ @message_class = message_class
27
+ end
28
+
29
+ # @attribute [String | Nil] The compression encoding.
30
+ attr_reader :encoding
31
+
32
+ # @attribute [Class | Nil] The expected message class for validation.
33
+ attr_reader :message_class
34
+
35
+ # Write a message with gRPC framing.
36
+ # @parameter message [Object, String] Protobuf message instance or raw binary data
37
+ # @parameter compressed [Boolean | Nil] Whether to compress this specific message. If `nil`, uses the encoding setting.
38
+ def write(message, compressed: nil)
39
+ # Validate message type if message_class is specified:
40
+ if @message_class && !message.is_a?(String)
41
+ unless message.is_a?(@message_class)
42
+ raise TypeError, "Expected #{@message_class}, got #{message.class}"
43
+ end
44
+ end
45
+
46
+ # Encode message to binary if it's not already a string:
47
+ # This supports both high-level (protobuf objects) and low-level (binary) usage
48
+ data = if message.is_a?(String)
49
+ message # Already binary, use as-is (for channel adapters)
50
+ elsif message.respond_to?(:to_proto)
51
+ # Use protobuf gem's to_proto method:
52
+ message.to_proto
53
+ elsif message.respond_to?(:encode)
54
+ # Use encode method:
55
+ message.encode
56
+ else
57
+ raise ArgumentError, "Message must respond to :to_proto or :encode"
58
+ end
59
+
60
+ # Determine if we should compress this message:
61
+ # If compressed param is nil, use the encoding setting
62
+ should_compress = compressed.nil? ? (@encoding && @encoding != "identity") : compressed
63
+
64
+ # Compress if requested:
65
+ data = compress(data) if should_compress
66
+
67
+ # Build prefix: compression flag + length
68
+ compression_flag = should_compress ? 1 : 0
69
+ length = data.bytesize
70
+ prefix = [compression_flag].pack("C") + [length].pack("N")
71
+
72
+ # Write prefix + data to underlying body:
73
+ super(prefix + data) # Call Protocol::HTTP::Body::Writable#write
74
+ end
75
+
76
+ protected
77
+
78
+ # Compress data using the configured encoding.
79
+ # @parameter data [String] The data to compress
80
+ # @returns [String] The compressed data
81
+ # @raises [Error] If compression fails
82
+ def compress(data)
83
+ case @encoding
84
+ when "gzip"
85
+ io = StringIO.new
86
+ gz = Zlib::GzipWriter.new(io, @level)
87
+ gz.write(data)
88
+ gz.close
89
+ io.string
90
+ when "deflate"
91
+ Zlib::Deflate.deflate(data, @level)
92
+ else
93
+ data # No compression or identity
94
+ end
95
+ rescue StandardError => error
96
+ raise Error.new(Status::INTERNAL, "Failed to compress message: #{error.message}")
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -3,99 +3,20 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2025, by Samuel Williams.
5
5
 
6
- require "protocol/http"
7
- require "protocol/http/body/writable"
8
- require "zlib"
9
- require "stringio"
6
+ # Compatibility shim for the old file name.
7
+ # This file is deprecated and will be removed in a future version.
8
+ # Please update your code to require 'protocol/grpc/body/writable' instead.
9
+
10
+ warn "Requiring 'protocol/grpc/body/writable_body' is deprecated. Please require 'protocol/grpc/body/writable' instead.", uplevel: 1 if $VERBOSE
11
+
12
+ require_relative "writable"
10
13
 
11
14
  module Protocol
12
15
  module GRPC
13
- # @namespace
14
16
  module Body
15
- # Represents a writable body for gRPC messages with length-prefixed framing.
16
- # This is the standard writable body for gRPC - all gRPC requests use message framing.
17
- class WritableBody < Protocol::HTTP::Body::Writable
18
- # Initialize a new writable body for gRPC messages.
19
- # @parameter encoding [String | Nil] Compression encoding (gzip, deflate, identity)
20
- # @parameter level [Integer] Compression level if encoding is used
21
- # @parameter message_class [Class | Nil] Expected message class for validation
22
- def initialize(encoding: nil, level: Zlib::DEFAULT_COMPRESSION, message_class: nil, **options)
23
- super(**options)
24
- @encoding = encoding
25
- @level = level
26
- @message_class = message_class
27
- end
28
-
29
- # @attribute [String | Nil] The compression encoding.
30
- attr_reader :encoding
31
-
32
- # @attribute [Class | Nil] The expected message class for validation.
33
- attr_reader :message_class
34
-
35
- # Write a message with gRPC framing.
36
- # @parameter message [Object, String] Protobuf message instance or raw binary data
37
- # @parameter compressed [Boolean | Nil] Whether to compress this specific message. If `nil`, uses the encoding setting.
38
- def write(message, compressed: nil)
39
- # Validate message type if message_class is specified:
40
- if @message_class && !message.is_a?(String)
41
- unless message.is_a?(@message_class)
42
- raise TypeError, "Expected #{@message_class}, got #{message.class}"
43
- end
44
- end
45
-
46
- # Encode message to binary if it's not already a string:
47
- # This supports both high-level (protobuf objects) and low-level (binary) usage
48
- data = if message.is_a?(String)
49
- message # Already binary, use as-is (for channel adapters)
50
- elsif message.respond_to?(:to_proto)
51
- # Use protobuf gem's to_proto method:
52
- message.to_proto
53
- elsif message.respond_to?(:encode)
54
- # Use encode method:
55
- message.encode
56
- else
57
- raise ArgumentError, "Message must respond to :to_proto or :encode"
58
- end
59
-
60
- # Determine if we should compress this message:
61
- # If compressed param is nil, use the encoding setting
62
- should_compress = compressed.nil? ? (@encoding && @encoding != "identity") : compressed
63
-
64
- # Compress if requested:
65
- data = compress(data) if should_compress
66
-
67
- # Build prefix: compression flag + length
68
- compression_flag = should_compress ? 1 : 0
69
- length = data.bytesize
70
- prefix = [compression_flag].pack("C") + [length].pack("N")
71
-
72
- # Write prefix + data to underlying body:
73
- super(prefix + data) # Call Protocol::HTTP::Body::Writable#write
74
- end
75
-
76
- protected
77
-
78
- # Compress data using the configured encoding.
79
- # @parameter data [String] The data to compress
80
- # @returns [String] The compressed data
81
- # @raises [Error] If compression fails
82
- def compress(data)
83
- case @encoding
84
- when "gzip"
85
- io = StringIO.new
86
- gz = Zlib::GzipWriter.new(io, @level)
87
- gz.write(data)
88
- gz.close
89
- io.string
90
- when "deflate"
91
- Zlib::Deflate.deflate(data, @level)
92
- else
93
- data # No compression or identity
94
- end
95
- rescue StandardError => error
96
- raise Error.new(Status::INTERNAL, "Failed to compress message: #{error.message}")
97
- end
98
- end
17
+ # Compatibility alias for the old class name.
18
+ # @deprecated Use {Writable} instead.
19
+ WritableBody = Writable
99
20
  end
100
21
  end
101
22
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require "uri"
7
+
8
+ module Protocol
9
+ module GRPC
10
+ module Header
11
+ # The `grpc-message` header represents the gRPC status message.
12
+ #
13
+ # The `grpc-message` header contains a human-readable error message, URL-encoded according to RFC 3986.
14
+ # This header is optional and typically only present when there's an error (non-zero status code).
15
+ # This header can appear both as an initial header (for trailers-only responses) and as a trailer.
16
+ class Message < String
17
+ # Parse a message from a header value.
18
+ #
19
+ # @parameter value [String] The header value to parse.
20
+ # @returns [Message] A new Message instance.
21
+ def self.parse(value)
22
+ new(value)
23
+ end
24
+
25
+ # Initialize the message header with the given value.
26
+ #
27
+ # @parameter value [String] The message value (will be URL-encoded if not already encoded).
28
+ def initialize(value)
29
+ super(value)
30
+ end
31
+
32
+ # Decode the URL-encoded message.
33
+ #
34
+ # @returns [String] The decoded message.
35
+ def decode
36
+ ::URI.decode_www_form_component(self)
37
+ end
38
+
39
+ # Encode the message for use in headers.
40
+ #
41
+ # @parameter message [String] The message to encode.
42
+ # @returns [String] The URL-encoded message.
43
+ def self.encode(message)
44
+ URI.encode_www_form_component(message).gsub("+", "%20")
45
+ end
46
+
47
+ # Merge another message value (takes the new value, as message should only appear once)
48
+ # @parameter value [String] The new message value
49
+ def <<(value)
50
+ replace(value.to_s)
51
+
52
+ return self
53
+ end
54
+
55
+ # Whether this header is acceptable in HTTP trailers.
56
+ # The `grpc-message` header can appear in trailers as per the gRPC specification.
57
+ # @returns [Boolean] `true`, as grpc-message can appear in trailers.
58
+ def self.trailer?
59
+ true
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require "protocol/http"
7
+
8
+ module Protocol
9
+ module GRPC
10
+ module Header
11
+ # Base class for custom gRPC metadata (allowed in trailers).
12
+ class Metadata < Protocol::HTTP::Header::Split
13
+ # Whether this header is acceptable in HTTP trailers.
14
+ # The `grpc-metadata` header can appear in trailers as per the gRPC specification.
15
+ # @returns [Boolean] `true`, as grpc-metadata can appear in trailers.
16
+ def self.trailer?
17
+ true
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ module Protocol
7
+ module GRPC
8
+ module Header
9
+ # The `grpc-status` header represents the gRPC status code.
10
+ #
11
+ # The `grpc-status` header contains a numeric status code (0-16) indicating the result of the RPC call.
12
+ # Status code 0 indicates success (OK), while other codes indicate various error conditions.
13
+ # This header can appear both as an initial header (for trailers-only responses) and as a trailer.
14
+ class Status
15
+ # Parse a status code from a header value.
16
+ #
17
+ # @parameter value [String] The header value to parse.
18
+ # @returns [Status] A new Status instance.
19
+ def self.parse(value)
20
+ new(value.to_i)
21
+ end
22
+
23
+ # Initialize the status header with the given value.
24
+ #
25
+ # @parameter value [String | Integer] The status code as a string or integer.
26
+ def initialize(value)
27
+ @value = value.to_i
28
+ end
29
+
30
+ # Get the status code as an integer.
31
+ #
32
+ # @returns [Integer] The status code.
33
+ def to_i
34
+ @value
35
+ end
36
+
37
+ # Serialize the status code to a string.
38
+ #
39
+ # @returns [String] The status code as a string.
40
+ def to_s
41
+ @value.to_s
42
+ end
43
+
44
+ # Check equality with another status or integer value.
45
+ #
46
+ # @parameter other [Status | Integer] The value to compare with.
47
+ # @returns [Boolean] if the status codes are equal.
48
+ def ==(other)
49
+ @value == other.to_i
50
+ end
51
+
52
+ alias eql? ==
53
+
54
+ # Generate hash for use in Hash/Set collections.
55
+ #
56
+ # @returns [Integer] The hash value based on the status code.
57
+ def hash
58
+ @value.hash
59
+ end
60
+
61
+ # Check if this status represents success (status code 0).
62
+ #
63
+ # @returns [Boolean] `true` if the status code is 0 (OK).
64
+ def ok?
65
+ @value == 0
66
+ end
67
+
68
+ # Merge another status value (takes the new value, as status should only appear once)
69
+ # @parameter value [String | Integer] The new status code
70
+ def <<(value)
71
+ @value = value.to_i
72
+
73
+ return self
74
+ end
75
+
76
+ # Whether this header is acceptable in HTTP trailers.
77
+ # The `grpc-status` header can appear in trailers as per the gRPC specification.
78
+ # @returns [Boolean] `true`, as grpc-status can appear in trailers.
79
+ def self.trailer?
80
+ true
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end