protocol-grpc 0.1.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,165 @@
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 "zlib"
8
+
9
+ module Protocol
10
+ module GRPC
11
+ # @namespace
12
+ module Body
13
+ # Represents a readable body for gRPC messages with length-prefixed framing.
14
+ # This is the standard readable body for gRPC - all gRPC responses use message framing.
15
+ class ReadableBody
16
+ # Initialize a new readable body for gRPC messages.
17
+ # @parameter body [Protocol::HTTP::Body::Readable] The underlying HTTP body
18
+ # @parameter message_class [Class | Nil] Protobuf message class with .decode method.
19
+ # If `nil`, returns raw binary data (useful for channel adapters)
20
+ # @parameter encoding [String | Nil] Compression encoding (from grpc-encoding header)
21
+ def initialize(body, message_class: nil, encoding: nil)
22
+ @body = body
23
+ @message_class = message_class
24
+ @encoding = encoding
25
+ @buffer = String.new.force_encoding(Encoding::BINARY)
26
+ @closed = false
27
+ end
28
+
29
+ # @attribute [Protocol::HTTP::Body::Readable] The underlying HTTP body.
30
+ attr_reader :body
31
+
32
+ # @attribute [String | Nil] The compression encoding.
33
+ attr_reader :encoding
34
+
35
+ # Close the input body.
36
+ # @parameter error [Exception | Nil] Optional error that caused the close
37
+ # @returns [Nil]
38
+ def close(error = nil)
39
+ @closed = true
40
+
41
+ if @body
42
+ @body.close(error)
43
+ @body = nil
44
+ end
45
+
46
+ nil
47
+ end
48
+
49
+ # Check if the stream has been closed.
50
+ # @returns [Boolean] `true` if the stream is closed, `false` otherwise
51
+ def closed?
52
+ @closed or @body.nil?
53
+ end
54
+
55
+ # Check if there are any input chunks remaining.
56
+ # @returns [Boolean] `true` if the body is empty, `false` otherwise
57
+ def empty?
58
+ @body.nil?
59
+ end
60
+
61
+ # Read the next gRPC message.
62
+ # @returns [Object | String | Nil] Decoded message, raw binary, or `Nil` if stream ended
63
+ def read
64
+ return nil if closed?
65
+
66
+ # Read 5-byte prefix: 1 byte compression flag + 4 bytes length
67
+ prefix = read_exactly(5)
68
+ return nil unless prefix
69
+
70
+ compressed = prefix[0].unpack1("C") == 1
71
+ length = prefix[1..4].unpack1("N")
72
+
73
+ # Read the message body:
74
+ data = read_exactly(length)
75
+ return nil unless data
76
+
77
+ # Decompress if needed:
78
+ data = decompress(data) if compressed
79
+
80
+ # Decode using message class if provided, otherwise return binary:
81
+ # This allows binary mode for channel adapters
82
+ if @message_class
83
+ # Use protobuf gem's decode method:
84
+ @message_class.decode(data)
85
+ else
86
+ data # Return raw binary
87
+ end
88
+ end
89
+
90
+ # Enumerate all messages until finished, then invoke {close}.
91
+ # @yields {|message| ...} The block to call with each message.
92
+ def each
93
+ return to_enum unless block_given?
94
+
95
+ error = nil
96
+ begin
97
+ while (message = read)
98
+ yield message
99
+ end
100
+ rescue StandardError => e
101
+ error = e
102
+ raise
103
+ ensure
104
+ close(error)
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ # Read exactly n bytes from the underlying body.
111
+ # @parameter n [Integer] The number of bytes to read
112
+ # @returns [String | Nil] The data read, or `Nil` if the stream ended
113
+ def read_exactly(n)
114
+ # Fill buffer until we have enough data:
115
+ while @buffer.bytesize < n
116
+ return nil if closed?
117
+
118
+ # Read chunk from underlying body:
119
+ chunk = @body.read
120
+
121
+ if chunk.nil?
122
+ # End of stream:
123
+ if @body && !@closed
124
+ @body.close
125
+ @closed = true
126
+ end
127
+ return nil
128
+ end
129
+
130
+ # Append to buffer:
131
+ @buffer << chunk.force_encoding(Encoding::BINARY)
132
+
133
+ # Check if body is empty and close if needed:
134
+ if @body.empty?
135
+ @body.close
136
+ @closed = true
137
+ end
138
+ end
139
+
140
+ # Extract the required data:
141
+ data = @buffer[0...n]
142
+ @buffer = @buffer[n..]
143
+ data
144
+ end
145
+
146
+ # Decompress data using the configured encoding.
147
+ # @parameter data [String] The compressed data
148
+ # @returns [String] The decompressed data
149
+ # @raises [Error] If decompression fails
150
+ def decompress(data)
151
+ case @encoding
152
+ when "gzip"
153
+ Zlib::Gunzip.new.inflate(data)
154
+ when "deflate"
155
+ Zlib::Inflate.inflate(data)
156
+ else
157
+ data
158
+ end
159
+ rescue StandardError => error
160
+ raise Error.new(Status::INTERNAL, "Failed to decompress message: #{error.message}")
161
+ end
162
+ end
163
+ end
164
+ end
165
+ 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 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
99
+ end
100
+ end
101
+ 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 "async/deadline"
7
+ require_relative "methods"
8
+
9
+ module Protocol
10
+ module GRPC
11
+ # Represents context for a single RPC call.
12
+ class Call
13
+ # Initialize a new RPC call context.
14
+ # @parameter request [Protocol::HTTP::Request] The HTTP request
15
+ # @parameter deadline [Async::Deadline | Nil] Deadline for the call
16
+ def initialize(request, deadline: nil)
17
+ @request = request
18
+ @deadline = deadline
19
+ @cancelled = false
20
+ end
21
+
22
+ # @attribute [Protocol::HTTP::Request] The underlying HTTP request.
23
+ attr_reader :request
24
+
25
+ # @attribute [Async::Deadline | Nil] The deadline for this call.
26
+ attr_reader :deadline
27
+
28
+ # Extract metadata from request headers.
29
+ # @returns [Hash] Custom metadata key-value pairs
30
+ def metadata
31
+ @metadata ||= Methods.extract_metadata(@request.headers)
32
+ end
33
+
34
+ # Check if the deadline has expired.
35
+ # @returns [Boolean] `true` if the deadline has expired, `false` otherwise
36
+ def deadline_exceeded?
37
+ @deadline&.expired? || false
38
+ end
39
+
40
+ # Get the time remaining until the deadline.
41
+ # @returns [Numeric | Nil] Seconds remaining, or `Nil` if no deadline is set
42
+ def time_remaining
43
+ @deadline&.remaining
44
+ end
45
+
46
+ # Mark this call as cancelled.
47
+ def cancel!
48
+ @cancelled = true
49
+ end
50
+
51
+ # Check if the call was cancelled.
52
+ # @returns [Boolean] `true` if the call was cancelled, `false` otherwise
53
+ def cancelled?
54
+ @cancelled
55
+ end
56
+
57
+ # Get peer information (client address).
58
+ # @returns [String | Nil] The peer address as a string, or `Nil` if not available
59
+ def peer
60
+ @request.peer&.to_s
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,133 @@
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
+ # Base exception class for gRPC errors
9
+ class Error < StandardError
10
+ attr_reader :status_code, :details, :metadata
11
+
12
+ # @parameter status_code [Integer] gRPC status code
13
+ # @parameter message [String | Nil] Error message
14
+ # @parameter details [Object | Nil] Error details
15
+ # @parameter metadata [Hash] Custom metadata
16
+ def initialize(status_code, message = nil, details: nil, metadata: {})
17
+ @status_code = status_code
18
+ @details = details
19
+ @metadata = metadata
20
+ super(message || Status::DESCRIPTIONS[status_code])
21
+ end
22
+
23
+ # Map status code to error class
24
+ # @parameter status_code [Integer] gRPC status code
25
+ # @returns [Class] Error class for the status code
26
+ def self.error_class_for_status(status_code)
27
+ case status_code
28
+ when Status::CANCELLED then Cancelled
29
+ when Status::INVALID_ARGUMENT then InvalidArgument
30
+ when Status::DEADLINE_EXCEEDED then DeadlineExceeded
31
+ when Status::NOT_FOUND then NotFound
32
+ when Status::INTERNAL then Internal
33
+ when Status::UNAVAILABLE then Unavailable
34
+ when Status::UNAUTHENTICATED then Unauthenticated
35
+ else
36
+ Error
37
+ end
38
+ end
39
+
40
+ # Create an appropriate error instance for the given status code
41
+ # @parameter status_code [Integer] gRPC status code
42
+ # @parameter message [String | Nil] Error message
43
+ # @parameter metadata [Hash] Custom metadata
44
+ # @returns [Error] An instance of the appropriate error class
45
+ def self.for(status_code, message = nil, metadata: {})
46
+ error_class = error_class_for_status(status_code)
47
+
48
+ if error_class == Error
49
+ error_class.new(status_code, message, metadata: metadata)
50
+ else
51
+ error_class.new(message, metadata: metadata)
52
+ end
53
+ end
54
+ end
55
+
56
+ # Represents a cancelled gRPC operation.
57
+ class Cancelled < Error
58
+ # Initialize a cancelled error.
59
+ # @parameter message [String | Nil] Optional error message
60
+ # @option options [Object | Nil] :details Optional error details
61
+ # @option options [Hash] :metadata Custom metadata
62
+ def initialize(message = nil, **options)
63
+ super(Status::CANCELLED, message, **options)
64
+ end
65
+ end
66
+
67
+ # Represents an invalid argument error.
68
+ class InvalidArgument < Error
69
+ # Initialize an invalid argument error.
70
+ # @parameter message [String | Nil] Optional error message
71
+ # @option options [Object | Nil] :details Optional error details
72
+ # @option options [Hash] :metadata Custom metadata
73
+ def initialize(message = nil, **options)
74
+ super(Status::INVALID_ARGUMENT, message, **options)
75
+ end
76
+ end
77
+
78
+ # Represents a deadline exceeded error.
79
+ class DeadlineExceeded < Error
80
+ # Initialize a deadline exceeded error.
81
+ # @parameter message [String | Nil] Optional error message
82
+ # @option options [Object | Nil] :details Optional error details
83
+ # @option options [Hash] :metadata Custom metadata
84
+ def initialize(message = nil, **options)
85
+ super(Status::DEADLINE_EXCEEDED, message, **options)
86
+ end
87
+ end
88
+
89
+ # Represents a not found error.
90
+ class NotFound < Error
91
+ # Initialize a not found error.
92
+ # @parameter message [String | Nil] Optional error message
93
+ # @option options [Object | Nil] :details Optional error details
94
+ # @option options [Hash] :metadata Custom metadata
95
+ def initialize(message = nil, **options)
96
+ super(Status::NOT_FOUND, message, **options)
97
+ end
98
+ end
99
+
100
+ # Represents an internal server error.
101
+ class Internal < Error
102
+ # Initialize an internal server error.
103
+ # @parameter message [String | Nil] Optional error message
104
+ # @option options [Object | Nil] :details Optional error details
105
+ # @option options [Hash] :metadata Custom metadata
106
+ def initialize(message = nil, **options)
107
+ super(Status::INTERNAL, message, **options)
108
+ end
109
+ end
110
+
111
+ # Represents an unavailable service error.
112
+ class Unavailable < Error
113
+ # Initialize an unavailable service error.
114
+ # @parameter message [String | Nil] Optional error message
115
+ # @option options [Object | Nil] :details Optional error details
116
+ # @option options [Hash] :metadata Custom metadata
117
+ def initialize(message = nil, **options)
118
+ super(Status::UNAVAILABLE, message, **options)
119
+ end
120
+ end
121
+
122
+ # Represents an unauthenticated error.
123
+ class Unauthenticated < Error
124
+ # Initialize an unauthenticated error.
125
+ # @parameter message [String | Nil] Optional error message
126
+ # @option options [Object | Nil] :details Optional error details
127
+ # @option options [Hash] :metadata Custom metadata
128
+ def initialize(message = nil, **options)
129
+ super(Status::UNAUTHENTICATED, message, **options)
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,119 @@
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 "uri"
8
+
9
+ require_relative "status"
10
+
11
+ module Protocol
12
+ module GRPC
13
+ # @namespace
14
+ module Header
15
+ # The `grpc-status` header represents the gRPC status code.
16
+ #
17
+ # The `grpc-status` header contains a numeric status code (0-16) indicating the result of the RPC call.
18
+ # Status code 0 indicates success (OK), while other codes indicate various error conditions.
19
+ # This header can appear both as an initial header (for trailers-only responses) and as a trailer.
20
+ class Status
21
+ # Initialize the status header with the given value.
22
+ #
23
+ # @parameter value [String, Integer] The status code as a string or integer.
24
+ def initialize(value)
25
+ @value = value.is_a?(String) ? value.to_i : value.to_i
26
+ end
27
+
28
+ # Get the status code as an integer.
29
+ #
30
+ # @returns [Integer] The status code.
31
+ def to_i
32
+ @value
33
+ end
34
+
35
+ # Serialize the status code to a string.
36
+ #
37
+ # @returns [String] The status code as a string.
38
+ def to_s
39
+ @value.to_s
40
+ end
41
+
42
+ # Merge another status value (takes the new value, as status should only appear once)
43
+ # @parameter value [String, Integer] The new status code
44
+ def <<(value)
45
+ @value = value.is_a?(String) ? value.to_i : value.to_i
46
+ self
47
+ end
48
+
49
+ # Whether this header is acceptable in HTTP trailers.
50
+ # The `grpc-status` header can appear in trailers as per the gRPC specification.
51
+ # @returns [Boolean] `true`, as grpc-status can appear in trailers.
52
+ def self.trailer?
53
+ true
54
+ end
55
+ end
56
+
57
+ # The `grpc-message` header represents the gRPC status message.
58
+ #
59
+ # The `grpc-message` header contains a human-readable error message, URL-encoded according to RFC 3986.
60
+ # This header is optional and typically only present when there's an error (non-zero status code).
61
+ # This header can appear both as an initial header (for trailers-only responses) and as a trailer.
62
+ class Message < String
63
+ # Initialize the message header with the given value.
64
+ #
65
+ # @parameter value [String] The message value (will be URL-encoded if not already encoded).
66
+ def initialize(value)
67
+ super(value.to_s)
68
+ end
69
+
70
+ # Decode the URL-encoded message.
71
+ #
72
+ # @returns [String] The decoded message.
73
+ def decode
74
+ URI.decode_www_form_component(self)
75
+ end
76
+
77
+ # Encode the message for use in headers.
78
+ #
79
+ # @parameter message [String] The message to encode.
80
+ # @returns [String] The URL-encoded message.
81
+ def self.encode(message)
82
+ URI.encode_www_form_component(message).gsub("+", "%20")
83
+ end
84
+
85
+ # Merge another message value (takes the new value, as message should only appear once)
86
+ # @parameter value [String] The new message value
87
+ def <<(value)
88
+ replace(value.to_s)
89
+ self
90
+ end
91
+
92
+ # Whether this header is acceptable in HTTP trailers.
93
+ # The `grpc-message` header can appear in trailers as per the gRPC specification.
94
+ # @returns [Boolean] `true`, as grpc-message can appear in trailers.
95
+ def self.trailer?
96
+ true
97
+ end
98
+ end
99
+
100
+ # Base class for custom gRPC metadata (allowed in trailers).
101
+ class Metadata < Protocol::HTTP::Header::Split
102
+ # Whether this header is acceptable in HTTP trailers.
103
+ # The `grpc-metadata` header can appear in trailers as per the gRPC specification.
104
+ # @returns [Boolean] `true`, as grpc-metadata can appear in trailers.
105
+ def self.trailer?
106
+ true
107
+ end
108
+ end
109
+ end
110
+
111
+ # Custom header policy for gRPC.
112
+ # Extends Protocol::HTTP::Headers::POLICY with gRPC-specific headers.
113
+ HEADER_POLICY = Protocol::HTTP::Headers::POLICY.merge(
114
+ "grpc-status" => Header::Status,
115
+ "grpc-message" => Header::Message
116
+ # By default, all other headers follow standard HTTP policy, but gRPC allows most metadata to be sent as trailers.
117
+ ).freeze
118
+ end
119
+ end
@@ -0,0 +1,19 @@
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
+ # @namespace
9
+ module HealthCheck
10
+ # Health check status constants
11
+ module ServingStatus
12
+ UNKNOWN = 0
13
+ SERVING = 1
14
+ NOT_SERVING = 2
15
+ SERVICE_UNKNOWN = 3
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2025, by Samuel Williams.
5
+
6
+ require_relative "methods"
7
+
8
+ module Protocol
9
+ module GRPC
10
+ # RPC method definition
11
+ RPC = Struct.new(:request_class, :response_class, :streaming, :method, keyword_init: true) do
12
+ def initialize(request_class:, response_class:, streaming: :unary, method: nil)
13
+ super
14
+ end
15
+ end
16
+
17
+ # Represents an interface definition for gRPC methods.
18
+ # Can be used by both client stubs and server implementations.
19
+ class Interface
20
+ # Hook called when a subclass is created.
21
+ # Initializes the RPC hash for the subclass.
22
+ # @parameter subclass [Class] The subclass being created
23
+ def self.inherited(subclass)
24
+ super
25
+
26
+ subclass.instance_variable_set(:@rpcs, {})
27
+ end
28
+
29
+ # Define an RPC method.
30
+ # @parameter name [Symbol] Method name in PascalCase (e.g., :SayHello, matching .proto file)
31
+ # @parameter request_class [Class] Request message class
32
+ # @parameter response_class [Class] Response message class
33
+ # @parameter streaming [Symbol] Streaming type (:unary, :server_streaming, :client_streaming, :bidirectional)
34
+ # @parameter method [Symbol | Nil] Optional explicit Ruby method name (snake_case). If not provided, automatically converts PascalCase to snake_case.
35
+ def self.rpc(name, **options)
36
+ @rpcs[name] = RPC.new(**options)
37
+ end
38
+
39
+ # Look up RPC definition for a method.
40
+ # Looks up the inheritance chain to find the RPC definition.
41
+ # @parameter name [Symbol] Method name.
42
+ # @returns [Protocol::GRPC::RPC | Nil] RPC definition or `Nil` if not found.
43
+ def self.lookup_rpc(name)
44
+ klass = self
45
+ while klass && klass != Interface
46
+ if klass.instance_variable_defined?(:@rpcs)
47
+ rpc = klass.instance_variable_get(:@rpcs)[name]
48
+ return rpc if rpc
49
+ end
50
+ klass = klass.superclass
51
+ end
52
+ nil
53
+ end
54
+
55
+ # Get all RPC definitions from this class and all parent classes.
56
+ # @returns [Hash] All RPC definitions merged from inheritance chain
57
+ def self.rpcs
58
+ all_rpcs = {}
59
+ klass = self
60
+
61
+ # Walk up the inheritance chain:
62
+ while klass && klass != Interface
63
+ if klass.instance_variable_defined?(:@rpcs)
64
+ all_rpcs.merge!(klass.instance_variable_get(:@rpcs))
65
+ end
66
+ klass = klass.superclass
67
+ end
68
+
69
+ all_rpcs
70
+ end
71
+
72
+ # @attribute [String] The service name (e.g., "hello.Greeter").
73
+ attr :name
74
+
75
+ # Initialize a new interface instance.
76
+ # @parameter name [String] Service name
77
+ def initialize(name)
78
+ @name = name
79
+ end
80
+
81
+ # Build gRPC path for a method.
82
+ # @parameter method_name [String, Symbol] Method name in PascalCase (e.g., :SayHello)
83
+ # @returns [String] gRPC path with PascalCase method name
84
+ def path(method_name)
85
+ Methods.build_path(@name, method_name.to_s)
86
+ end
87
+ end
88
+ end
89
+ end