diff --git a/design.md b/design.md index 06e555c..ed06296 100644 --- a/design.md +++ b/design.md @@ -140,7 +140,7 @@ module Protocol # Parse service and method from gRPC path # @parameter path [String] e.g., "/my_service.Greeter/SayHello" - # @returns [Array(String, String)] [service, method] + # @returns [Tuple(String, String)] of service and method. def self.parse_path(path) parts = path.split("/") [parts[1], parts[2]] @@ -287,7 +287,7 @@ module Protocol # Extract gRPC status message from headers # @parameter headers [Protocol::HTTP::Headers] - # @returns [String, nil] Status message + # @returns [String | Nil] Status message def self.extract_message(headers) message = headers["grpc-message"] message ? URI.decode_www_form_component(message) : nil @@ -329,7 +329,7 @@ module Protocol # @parameter body [Protocol::HTTP::Body::Readable] The underlying HTTP body # @parameter message_class [Class, nil] Protobuf message class with .decode method # If nil, returns raw binary data (useful for channel adapters) - # @parameter encoding [String, nil] Compression encoding (from grpc-encoding header) + # @parameter encoding [String | Nil] Compression encoding (from grpc-encoding header) def initialize(body, message_class: nil, encoding: nil) super(body) @message_class = message_class @@ -408,7 +408,7 @@ module Protocol # Writes length-prefixed gRPC messages # This is the standard writable body for gRPC - all gRPC requests use message framing class Writable < Protocol::HTTP::Body::Writable - # @parameter encoding [String, nil] Compression encoding (gzip, deflate, identity) + # @parameter encoding [String | Nil] Compression encoding (gzip, deflate, identity) # @parameter level [Integer] Compression level if encoding is used def initialize(encoding: nil, level: Zlib::DEFAULT_COMPRESSION, **options) super(**options) @@ -574,7 +574,7 @@ module Protocol end # Get peer information (client address) - # @returns [String, nil] + # @returns [String | Nil] def peer @request.peer&.to_s end diff --git a/lib/protocol/grpc/header.rb b/lib/protocol/grpc/header.rb index 71fcdb4..9e74308 100644 --- a/lib/protocol/grpc/header.rb +++ b/lib/protocol/grpc/header.rb @@ -4,120 +4,16 @@ # Copyright, 2025, by Samuel Williams. require "protocol/http" -require "uri" require_relative "status" +require_relative "header/status" +require_relative "header/message" +require_relative "header/metadata" module Protocol module GRPC # @namespace module Header - # The `grpc-status` header represents the gRPC status code. - # - # The `grpc-status` header contains a numeric status code (0-16) indicating the result of the RPC call. - # Status code 0 indicates success (OK), while other codes indicate various error conditions. - # This header can appear both as an initial header (for trailers-only responses) and as a trailer. - class Status - # Initialize the status header with the given value. - # - # @parameter value [String, Integer, Array] The status code as a string, integer, or array (takes first element). - def initialize(value) - @value = normalize_value(value) - end - - # Get the status code as an integer. - # - # @returns [Integer] The status code. - def to_i - @value - end - - # Serialize the status code to a string. - # - # @returns [String] The status code as a string. - def to_s - @value.to_s - end - - # Merge another status value (takes the new value, as status should only appear once) - # @parameter value [String, Integer, Array] The new status code - def <<(value) - @value = normalize_value(value) - self - end - - private - - # Normalize a value to an integer status code. - # Handles arrays (from external clients), strings, and integers. - # @parameter value [String, Integer, Array] The raw value - # @returns [Integer] The normalized status code - def normalize_value(value) - # Handle Array case (may occur with external clients) - actual_value = value.is_a?(Array) ? value.flatten.compact.first : value - actual_value.to_i - end - - # Whether this header is acceptable in HTTP trailers. - # The `grpc-status` header can appear in trailers as per the gRPC specification. - # @returns [Boolean] `true`, as grpc-status can appear in trailers. - def self.trailer? - true - end - end - - # The `grpc-message` header represents the gRPC status message. - # - # The `grpc-message` header contains a human-readable error message, URL-encoded according to RFC 3986. - # This header is optional and typically only present when there's an error (non-zero status code). - # This header can appear both as an initial header (for trailers-only responses) and as a trailer. - class Message < String - # Initialize the message header with the given value. - # - # @parameter value [String] The message value (will be URL-encoded if not already encoded). - def initialize(value) - super(value.to_s) - end - - # Decode the URL-encoded message. - # - # @returns [String] The decoded message. - def decode - URI.decode_www_form_component(self) - end - - # Encode the message for use in headers. - # - # @parameter message [String] The message to encode. - # @returns [String] The URL-encoded message. - def self.encode(message) - URI.encode_www_form_component(message).gsub("+", "%20") - end - - # Merge another message value (takes the new value, as message should only appear once) - # @parameter value [String] The new message value - def <<(value) - replace(value.to_s) - self - end - - # Whether this header is acceptable in HTTP trailers. - # The `grpc-message` header can appear in trailers as per the gRPC specification. - # @returns [Boolean] `true`, as grpc-message can appear in trailers. - def self.trailer? - true - end - end - - # Base class for custom gRPC metadata (allowed in trailers). - class Metadata < Protocol::HTTP::Header::Split - # Whether this header is acceptable in HTTP trailers. - # The `grpc-metadata` header can appear in trailers as per the gRPC specification. - # @returns [Boolean] `true`, as grpc-metadata can appear in trailers. - def self.trailer? - true - end - end end # Custom header policy for gRPC. diff --git a/lib/protocol/grpc/header/message.rb b/lib/protocol/grpc/header/message.rb new file mode 100644 index 0000000..acf5ac5 --- /dev/null +++ b/lib/protocol/grpc/header/message.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "uri" + +module Protocol + module GRPC + module Header + # The `grpc-message` header represents the gRPC status message. + # + # The `grpc-message` header contains a human-readable error message, URL-encoded according to RFC 3986. + # This header is optional and typically only present when there's an error (non-zero status code). + # This header can appear both as an initial header (for trailers-only responses) and as a trailer. + class Message < String + # Parse a message from a header value. + # + # @parameter value [String] The header value to parse. + # @returns [Message] A new Message instance. + def self.parse(value) + new(value) + end + + # Initialize the message header with the given value. + # + # @parameter value [String] The message value (will be URL-encoded if not already encoded). + def initialize(value) + super(value) + end + + # Decode the URL-encoded message. + # + # @returns [String] The decoded message. + def decode + ::URI.decode_www_form_component(self) + end + + # Encode the message for use in headers. + # + # @parameter message [String] The message to encode. + # @returns [String] The URL-encoded message. + def self.encode(message) + URI.encode_www_form_component(message).gsub("+", "%20") + end + + # Merge another message value (takes the new value, as message should only appear once) + # @parameter value [String] The new message value + def <<(value) + replace(value.to_s) + + return self + end + + # Whether this header is acceptable in HTTP trailers. + # The `grpc-message` header can appear in trailers as per the gRPC specification. + # @returns [Boolean] `true`, as grpc-message can appear in trailers. + def self.trailer? + true + end + end + end + end +end diff --git a/lib/protocol/grpc/header/metadata.rb b/lib/protocol/grpc/header/metadata.rb new file mode 100644 index 0000000..0ba2856 --- /dev/null +++ b/lib/protocol/grpc/header/metadata.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/http" + +module Protocol + module GRPC + module Header + # Base class for custom gRPC metadata (allowed in trailers). + class Metadata < Protocol::HTTP::Header::Split + # Whether this header is acceptable in HTTP trailers. + # The `grpc-metadata` header can appear in trailers as per the gRPC specification. + # @returns [Boolean] `true`, as grpc-metadata can appear in trailers. + def self.trailer? + true + end + end + end + end +end diff --git a/lib/protocol/grpc/header/status.rb b/lib/protocol/grpc/header/status.rb new file mode 100644 index 0000000..69cfd66 --- /dev/null +++ b/lib/protocol/grpc/header/status.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +module Protocol + module GRPC + module Header + # The `grpc-status` header represents the gRPC status code. + # + # The `grpc-status` header contains a numeric status code (0-16) indicating the result of the RPC call. + # Status code 0 indicates success (OK), while other codes indicate various error conditions. + # This header can appear both as an initial header (for trailers-only responses) and as a trailer. + class Status + # Parse a status code from a header value. + # + # @parameter value [String] The header value to parse. + # @returns [Status] A new Status instance. + def self.parse(value) + new(value.to_i) + end + + # Initialize the status header with the given value. + # + # @parameter value [String | Integer] The status code as a string or integer. + def initialize(value) + @value = value.to_i + end + + # Get the status code as an integer. + # + # @returns [Integer] The status code. + def to_i + @value + end + + # Serialize the status code to a string. + # + # @returns [String] The status code as a string. + def to_s + @value.to_s + end + + # Check equality with another status or integer value. + # + # @parameter other [Status | Integer] The value to compare with. + # @returns [Boolean] if the status codes are equal. + def ==(other) + @value == other.to_i + end + + alias eql? == + + # Generate hash for use in Hash/Set collections. + # + # @returns [Integer] The hash value based on the status code. + def hash + @value.hash + end + + # Check if this status represents success (status code 0). + # + # @returns [Boolean] `true` if the status code is 0 (OK). + def ok? + @value == 0 + end + + # Merge another status value (takes the new value, as status should only appear once) + # @parameter value [String | Integer] The new status code + def <<(value) + @value = value.to_i + + return self + end + + # Whether this header is acceptable in HTTP trailers. + # The `grpc-status` header can appear in trailers as per the gRPC specification. + # @returns [Boolean] `true`, as grpc-status can appear in trailers. + def self.trailer? + true + end + end + end + end +end diff --git a/lib/protocol/grpc/interface.rb b/lib/protocol/grpc/interface.rb index 9c847f3..bf5e238 100644 --- a/lib/protocol/grpc/interface.rb +++ b/lib/protocol/grpc/interface.rb @@ -10,6 +10,8 @@ module GRPC # Wrapper class to mark a message type as streamed. # Used with the stream() helper method in RPC definitions. class Streaming + # Initialize a new Streaming wrapper. + # @parameter message_class [Class] The message class being wrapped def initialize(message_class) @message_class = message_class end @@ -148,7 +150,7 @@ def initialize(name) attr :name # Build gRPC path for a method. - # @parameter method_name [String, Symbol] Method name in PascalCase (e.g., :SayHello) + # @parameter method_name [String | Symbol] Method name in PascalCase (e.g., :SayHello) # @returns [String] gRPC path with PascalCase method name def path(method_name) Methods.build_path(@name, method_name.to_s) diff --git a/lib/protocol/grpc/methods.rb b/lib/protocol/grpc/methods.rb index b0b2473..a2dd171 100644 --- a/lib/protocol/grpc/methods.rb +++ b/lib/protocol/grpc/methods.rb @@ -20,7 +20,7 @@ def self.build_path(service, method) # Parse service and method from gRPC path. # @parameter path [String] e.g., "/my_service.Greeter/SayHello" - # @returns [Array(String, String)] [service, method] + # @returns [Array(String | String)] [service, method] def self.parse_path(path) parts = path.split("/") [parts[1], parts[2]] diff --git a/test/protocol/grpc/header.rb b/test/protocol/grpc/header.rb new file mode 100644 index 0000000..90aac6c --- /dev/null +++ b/test/protocol/grpc/header.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/grpc/header" + +describe Protocol::GRPC::HEADER_POLICY do + it "includes grpc-status mapping" do + expect(subject["grpc-status"]).to be == Protocol::GRPC::Header::Status + end + + it "includes grpc-message mapping" do + expect(subject["grpc-message"]).to be == Protocol::GRPC::Header::Message + end + + it "is frozen" do + expect(subject).to be(:frozen?) + end +end diff --git a/test/protocol/grpc/header/message.rb b/test/protocol/grpc/header/message.rb new file mode 100644 index 0000000..6443947 --- /dev/null +++ b/test/protocol/grpc/header/message.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/grpc/header/message" + +describe Protocol::GRPC::Header::Message do + with ".parse" do + it "parses string value" do + message = subject.parse("Error%20occurred") + expect(message).to be == "Error%20occurred" + end + end + + with "#initialize" do + it "accepts string value" do + message = subject.new("Test message") + expect(message).to be == "Test message" + end + end + + with "#decode" do + it "decodes URL-encoded message" do + message = subject.new("Error%20occurred") + expect(message.decode).to be == "Error occurred" + end + + it "decodes special characters" do + message = subject.new("Error%3A%20not%20found") + expect(message.decode).to be == "Error: not found" + end + + it "handles unencoded message" do + message = subject.new("Simple error") + expect(message.decode).to be == "Simple error" + end + end + + with ".encode" do + it "encodes message for headers" do + encoded = subject.encode("Error occurred") + expect(encoded).to be == "Error%20occurred" + end + + it "encodes special characters" do + encoded = subject.encode("Error: not found") + expect(encoded).to be == "Error%3A%20not%20found" + end + + it "does not use plus for spaces" do + encoded = subject.encode("Test message") + expect(encoded).not.to be =~ /\+/ + expect(encoded).to be =~ /%20/ + end + end + + with "#<<" do + it "merges new string value" do + message = subject.new("Old message") + message << "New message" + expect(message).to be == "New message" + end + + it "converts non-string to string" do + message = subject.new("Old") + message << 123 + expect(message).to be == "123" + end + + it "returns self for chaining" do + message = subject.new("Old") + result = message << "New" + expect(result).to be_equal(message) + end + end + + with ".trailer?" do + it "returns true as grpc-message can appear in trailers" do + expect(subject).to be(:trailer?) + end + end +end diff --git a/test/protocol/grpc/header/metadata.rb b/test/protocol/grpc/header/metadata.rb new file mode 100644 index 0000000..fa1c034 --- /dev/null +++ b/test/protocol/grpc/header/metadata.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/grpc/header/metadata" + +describe Protocol::GRPC::Header::Metadata do + with ".trailer?" do + it "returns true as grpc-metadata can appear in trailers" do + expect(subject).to be(:trailer?) + end + end +end diff --git a/test/protocol/grpc/header/status.rb b/test/protocol/grpc/header/status.rb new file mode 100644 index 0000000..3f0b73e --- /dev/null +++ b/test/protocol/grpc/header/status.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Samuel Williams. + +require "protocol/grpc/header/status" + +describe Protocol::GRPC::Header::Status do + with ".parse" do + it "parses string value to integer" do + status = subject.parse("0") + expect(status.to_i).to be == 0 + end + + it "parses numeric string" do + status = subject.parse("14") + expect(status.to_i).to be == 14 + end + end + + with "#initialize" do + it "accepts integer value" do + status = subject.new(0) + expect(status.to_i).to be == 0 + end + + it "accepts string value" do + status = subject.new("5") + expect(status.to_i).to be == 5 + end + end + + with "#to_i" do + it "returns integer status code" do + status = subject.new(13) + expect(status.to_i).to be == 13 + end + end + + with "#to_s" do + it "returns string representation of status code" do + status = subject.new(0) + expect(status.to_s).to be == "0" + end + + it "converts integer to string" do + status = subject.new(14) + expect(status.to_s).to be == "14" + end + end + + with "#==" do + it "compares equal status instances" do + status1 = subject.new(0) + status2 = subject.new(0) + expect(status1).to be == status2 + end + + it "compares unequal status instances" do + status1 = subject.new(0) + status2 = subject.new(1) + expect(status1).not.to be == status2 + end + + it "compares status with integer" do + status = subject.new(5) + expect(status).to be == 5 + end + + it "compares status with different integer" do + status = subject.new(5) + expect(status).not.to be == 3 + end + end + + with "#eql?" do + it "works same as ==" do + status1 = subject.new(0) + status2 = subject.new(0) + expect(status1.eql?(status2)).to be == true + end + end + + with "#hash" do + it "returns consistent hash for same status code" do + status1 = subject.new(0) + status2 = subject.new(0) + expect(status1.hash).to be == status2.hash + end + + it "can be used as hash key" do + status = subject.new(0) + hash = {status => "success"} + expect(hash[subject.new(0)]).to be == "success" + end + end + + with "#ok?" do + it "returns true for status code 0" do + status = subject.new(0) + expect(status).to be(:ok?) + end + + it "returns false for non-zero status code" do + status = subject.new(1) + expect(status.ok?).to be == false + end + + it "returns false for error status" do + status = subject.new(14) + expect(status.ok?).to be == false + end + end + + with "#<<" do + it "merges new integer value" do + status = subject.new(0) + status << 1 + expect(status.to_i).to be == 1 + end + + it "merges new string value" do + status = subject.new(0) + status << "5" + expect(status.to_i).to be == 5 + end + + it "returns self for chaining" do + status = subject.new(0) + result = status << 1 + expect(result).to be_equal(status) + end + end + + with ".trailer?" do + it "returns true as grpc-status can appear in trailers" do + expect(subject).to be(:trailer?) + end + end +end