Skip to content

Commit bfe3eb1

Browse files
committed
Tidy up header implementation + tests.
1 parent 30a061d commit bfe3eb1

File tree

8 files changed

+336
-107
lines changed

8 files changed

+336
-107
lines changed

lib/protocol/grpc/header.rb

Lines changed: 3 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -4,120 +4,16 @@
44
# Copyright, 2025, by Samuel Williams.
55

66
require "protocol/http"
7-
require "uri"
87

98
require_relative "status"
9+
require_relative "header/status"
10+
require_relative "header/message"
11+
require_relative "header/metadata"
1012

1113
module Protocol
1214
module GRPC
1315
# @namespace
1416
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, Array] The status code as a string, integer, or array (takes first element).
24-
def initialize(value)
25-
@value = normalize_value(value)
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, Array] The new status code
44-
def <<(value)
45-
@value = normalize_value(value)
46-
self
47-
end
48-
49-
private
50-
51-
# Normalize a value to an integer status code.
52-
# Handles arrays (from external clients), strings, and integers.
53-
# @parameter value [String, Integer, Array] The raw value
54-
# @returns [Integer] The normalized status code
55-
def normalize_value(value)
56-
# Handle Array case (may occur with external clients)
57-
actual_value = value.is_a?(Array) ? value.flatten.compact.first : value
58-
actual_value.to_i
59-
end
60-
61-
# Whether this header is acceptable in HTTP trailers.
62-
# The `grpc-status` header can appear in trailers as per the gRPC specification.
63-
# @returns [Boolean] `true`, as grpc-status can appear in trailers.
64-
def self.trailer?
65-
true
66-
end
67-
end
68-
69-
# The `grpc-message` header represents the gRPC status message.
70-
#
71-
# The `grpc-message` header contains a human-readable error message, URL-encoded according to RFC 3986.
72-
# This header is optional and typically only present when there's an error (non-zero status code).
73-
# This header can appear both as an initial header (for trailers-only responses) and as a trailer.
74-
class Message < String
75-
# Initialize the message header with the given value.
76-
#
77-
# @parameter value [String] The message value (will be URL-encoded if not already encoded).
78-
def initialize(value)
79-
super(value.to_s)
80-
end
81-
82-
# Decode the URL-encoded message.
83-
#
84-
# @returns [String] The decoded message.
85-
def decode
86-
URI.decode_www_form_component(self)
87-
end
88-
89-
# Encode the message for use in headers.
90-
#
91-
# @parameter message [String] The message to encode.
92-
# @returns [String] The URL-encoded message.
93-
def self.encode(message)
94-
URI.encode_www_form_component(message).gsub("+", "%20")
95-
end
96-
97-
# Merge another message value (takes the new value, as message should only appear once)
98-
# @parameter value [String] The new message value
99-
def <<(value)
100-
replace(value.to_s)
101-
self
102-
end
103-
104-
# Whether this header is acceptable in HTTP trailers.
105-
# The `grpc-message` header can appear in trailers as per the gRPC specification.
106-
# @returns [Boolean] `true`, as grpc-message can appear in trailers.
107-
def self.trailer?
108-
true
109-
end
110-
end
111-
112-
# Base class for custom gRPC metadata (allowed in trailers).
113-
class Metadata < Protocol::HTTP::Header::Split
114-
# Whether this header is acceptable in HTTP trailers.
115-
# The `grpc-metadata` header can appear in trailers as per the gRPC specification.
116-
# @returns [Boolean] `true`, as grpc-metadata can appear in trailers.
117-
def self.trailer?
118-
true
119-
end
120-
end
12117
end
12218

12319
# Custom header policy for gRPC.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
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+
def self.parse(value)
18+
new(value)
19+
end
20+
21+
# Initialize the message header with the given value.
22+
#
23+
# @parameter value [String] The message value (will be URL-encoded if not already encoded).
24+
def initialize(value)
25+
super(value)
26+
end
27+
28+
# Decode the URL-encoded message.
29+
#
30+
# @returns [String] The decoded message.
31+
def decode
32+
::URI.decode_www_form_component(self)
33+
end
34+
35+
# Encode the message for use in headers.
36+
#
37+
# @parameter message [String] The message to encode.
38+
# @returns [String] The URL-encoded message.
39+
def self.encode(message)
40+
URI.encode_www_form_component(message).gsub("+", "%20")
41+
end
42+
43+
# Merge another message value (takes the new value, as message should only appear once)
44+
# @parameter value [String] The new message value
45+
def <<(value)
46+
replace(value.to_s)
47+
48+
return self
49+
end
50+
51+
# Whether this header is acceptable in HTTP trailers.
52+
# The `grpc-message` header can appear in trailers as per the gRPC specification.
53+
# @returns [Boolean] `true`, as grpc-message can appear in trailers.
54+
def self.trailer?
55+
true
56+
end
57+
end
58+
end
59+
end
60+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/protocol/grpc/header/status.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
def self.parse(value)
16+
new(value.to_i)
17+
end
18+
19+
# Initialize the status header with the given value.
20+
#
21+
# @parameter value [String, Integer] The status code as a string or integer.
22+
def initialize(value)
23+
@value = value.to_i
24+
end
25+
26+
# Get the status code as an integer.
27+
#
28+
# @returns [Integer] The status code.
29+
def to_i
30+
@value
31+
end
32+
33+
# Serialize the status code to a string.
34+
#
35+
# @returns [String] The status code as a string.
36+
def to_s
37+
@value.to_s
38+
end
39+
40+
# Merge another status value (takes the new value, as status should only appear once)
41+
# @parameter value [String, Integer] The new status code
42+
def <<(value)
43+
@value = value.to_i
44+
45+
return self
46+
end
47+
48+
# Whether this header is acceptable in HTTP trailers.
49+
# The `grpc-status` header can appear in trailers as per the gRPC specification.
50+
# @returns [Boolean] `true`, as grpc-status can appear in trailers.
51+
def self.trailer?
52+
true
53+
end
54+
end
55+
end
56+
end
57+
end

test/protocol/grpc/header.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "protocol/grpc/header"
7+
8+
describe Protocol::GRPC::HEADER_POLICY do
9+
it "includes grpc-status mapping" do
10+
expect(subject["grpc-status"]).to be == Protocol::GRPC::Header::Status
11+
end
12+
13+
it "includes grpc-message mapping" do
14+
expect(subject["grpc-message"]).to be == Protocol::GRPC::Header::Message
15+
end
16+
17+
it "is frozen" do
18+
expect(subject).to be(:frozen?)
19+
end
20+
end
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "protocol/grpc/header/message"
7+
8+
describe Protocol::GRPC::Header::Message do
9+
with ".parse" do
10+
it "parses string value" do
11+
message = subject.parse("Error%20occurred")
12+
expect(message).to be == "Error%20occurred"
13+
end
14+
end
15+
16+
with "#initialize" do
17+
it "accepts string value" do
18+
message = subject.new("Test message")
19+
expect(message).to be == "Test message"
20+
end
21+
end
22+
23+
with "#decode" do
24+
it "decodes URL-encoded message" do
25+
message = subject.new("Error%20occurred")
26+
expect(message.decode).to be == "Error occurred"
27+
end
28+
29+
it "decodes special characters" do
30+
message = subject.new("Error%3A%20not%20found")
31+
expect(message.decode).to be == "Error: not found"
32+
end
33+
34+
it "handles unencoded message" do
35+
message = subject.new("Simple error")
36+
expect(message.decode).to be == "Simple error"
37+
end
38+
end
39+
40+
with ".encode" do
41+
it "encodes message for headers" do
42+
encoded = subject.encode("Error occurred")
43+
expect(encoded).to be == "Error%20occurred"
44+
end
45+
46+
it "encodes special characters" do
47+
encoded = subject.encode("Error: not found")
48+
expect(encoded).to be == "Error%3A%20not%20found"
49+
end
50+
51+
it "does not use plus for spaces" do
52+
encoded = subject.encode("Test message")
53+
expect(encoded).not.to be =~ /\+/
54+
expect(encoded).to be =~ /%20/
55+
end
56+
end
57+
58+
with "#<<" do
59+
it "merges new string value" do
60+
message = subject.new("Old message")
61+
message << "New message"
62+
expect(message).to be == "New message"
63+
end
64+
65+
it "converts non-string to string" do
66+
message = subject.new("Old")
67+
message << 123
68+
expect(message).to be == "123"
69+
end
70+
71+
it "returns self for chaining" do
72+
message = subject.new("Old")
73+
result = message << "New"
74+
expect(result).to be_equal(message)
75+
end
76+
end
77+
78+
with ".trailer?" do
79+
it "returns true as grpc-message can appear in trailers" do
80+
expect(subject).to be(:trailer?)
81+
end
82+
end
83+
end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2025, by Samuel Williams.
5+
6+
require "protocol/grpc/header/metadata"
7+
8+
describe Protocol::GRPC::Header::Metadata do
9+
with ".trailer?" do
10+
it "returns true as grpc-metadata can appear in trailers" do
11+
expect(subject).to be(:trailer?)
12+
end
13+
end
14+
end

0 commit comments

Comments
 (0)