Skip to content

Commit d1ed74f

Browse files
authored
Merge pull request #42 from github/copilot/fix-41
task: implement header symbolization with opt-out configuration
2 parents 2a4f904 + c0b35e4 commit d1ed74f

File tree

10 files changed

+241
-23
lines changed

10 files changed

+241
-23
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
hooks-ruby (0.0.4)
4+
hooks-ruby (0.0.5)
55
dry-schema (~> 1.14, >= 1.14.1)
66
grape (~> 2.3)
77
puma (~> 6.6)

docs/handler_plugins.md

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ Handler plugins are Ruby classes that extend the `Hooks::Plugins::Handlers::Base
1515
class Example < Hooks::Plugins::Handlers::Base
1616
# Process a webhook payload
1717
#
18-
# @param payload [Hash, String] webhook payload
19-
# @param headers [Hash<String, String>] HTTP headers
18+
# @param payload [Hash, String] webhook payload (symbolized keys by default)
19+
# @param headers [Hash] HTTP headers (symbolized keys by default)
2020
# @param config [Hash] Endpoint configuration
2121
# @return [Hash] Response data
2222
def call(payload:, headers:, config:)
@@ -61,27 +61,45 @@ It will be parsed and passed to the handler as:
6161

6262
The `headers` parameter is a Hash that contains the HTTP headers that were sent with the webhook request. It includes standard headers like `host`, `user-agent`, `accept`, and any custom headers that the webhook sender may have included.
6363

64-
Here is an example of what the `headers` parameter might look like:
64+
By default, the headers are normalized (lowercased and trimmed) and then symbolized. This means that the keys in the headers will be converted to symbols, and any hyphens (`-`) in header names are converted to underscores (`_`). You can disable header symbolization by setting the environment variable `HOOKS_SYMBOLIZE_HEADERS` to `false` or by setting the `symbolize_headers` option to `false` in the global configuration file.
65+
66+
**TL;DR**: The headers are almost always a Hash with symbolized keys, with hyphens converted to underscores.
67+
68+
For example, if the client sends the following headers:
69+
70+
```
71+
Host: hooks.example.com
72+
User-Agent: foo-client/1.0
73+
Accept: application/json, text/plain, */*
74+
Accept-Encoding: gzip, compress, deflate, br
75+
Client-Name: foo
76+
X-Forwarded-For: <IP_ADDRESS>
77+
X-Forwarded-Host: hooks.example.com
78+
X-Forwarded-Proto: https
79+
Authorization: Bearer <TOKEN>
80+
```
81+
82+
They will be normalized and symbolized and passed to the handler as:
6583

6684
```ruby
67-
# example headers as a Hash
6885
{
69-
"host" => "<HOSTNAME>", # e.g., "hooks.example.com"
70-
"user-agent" => "foo-client/1.0",
71-
"accept" => "application/json, text/plain, */*",
72-
"accept-encoding" => "gzip, compress, deflate, br",
73-
"client-name" => "foo",
74-
"x-forwarded-for" => "<IP_ADDRESS>",
75-
"x-forwarded-host" => "<HOSTNAME>", # e.g., "hooks.example.com"
76-
"x-forwarded-proto" => "https",
77-
"version" => "HTTP/1.1",
78-
"Authorization" => "Bearer <TOKEN>" # a careful reminder that headers *can* contain sensitive information!
86+
host: "hooks.example.com",
87+
user_agent: "foo-client/1.0",
88+
accept: "application/json, text/plain, */*",
89+
accept_encoding: "gzip, compress, deflate, br",
90+
client_name: "foo",
91+
x_forwarded_for: "<IP_ADDRESS>",
92+
x_forwarded_host: "hooks.example.com",
93+
x_forwarded_proto: "https",
94+
authorization: "Bearer <TOKEN>" # a careful reminder that headers *can* contain sensitive information!
7995
}
8096
```
8197

82-
It should be noted that the `headers` parameter is a Hash with **String keys** (not symbols). They are also normalized (lowercased and trimmed) to ensure consistency.
98+
It should be noted that the `headers` parameter is a Hash with **symbolized keys** (not strings) by default. They are also normalized (lowercased and trimmed) to ensure consistency.
99+
100+
You can disable header symbolization by either setting the environment variable `HOOKS_SYMBOLIZE_HEADERS` to `false` or by setting the `symbolize_headers` option to `false` in the global configuration file.
83101

84-
You can disable this normalization by either setting the environment variable `HOOKS_NORMALIZE_HEADERS` to `false` or by setting the `normalize_headers` option to `false` in the global configuration file.
102+
You can disable header normalization by either setting the environment variable `HOOKS_NORMALIZE_HEADERS` to `false` or by setting the `normalize_headers` option to `false` in the global configuration file.
85103

86104
### `config` Parameter
87105

lib/hooks/app/api.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,11 @@ def self.create(config:, endpoints:, log:)
105105
payload = parse_payload(raw_body, headers, symbolize: config[:symbolize_payload])
106106
handler = load_handler(handler_class_name)
107107
normalized_headers = config[:normalize_headers] ? Hooks::Utils::Normalize.headers(headers) : headers
108+
symbolized_headers = config[:symbolize_headers] ? Hooks::Utils::Normalize.symbolize_headers(normalized_headers) : normalized_headers
108109

109110
response = handler.call(
110111
payload:,
111-
headers: normalized_headers,
112+
headers: symbolized_headers,
112113
config: endpoint_config
113114
)
114115

lib/hooks/core/config_loader.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ class ConfigLoader
2121
endpoints_dir: "./config/endpoints",
2222
use_catchall_route: false,
2323
symbolize_payload: true,
24-
normalize_headers: true
24+
normalize_headers: true,
25+
symbolize_headers: true
2526
}.freeze
2627

2728
SILENCE_CONFIG_LOADER_MESSAGES = ENV.fetch(
@@ -143,6 +144,7 @@ def self.load_env_config
143144
"HOOKS_USE_CATCHALL_ROUTE" => :use_catchall_route,
144145
"HOOKS_SYMBOLIZE_PAYLOAD" => :symbolize_payload,
145146
"HOOKS_NORMALIZE_HEADERS" => :normalize_headers,
147+
"HOOKS_SYMBOLIZE_HEADERS" => :symbolize_headers,
146148
"HOOKS_SOME_STRING_VAR" => :some_string_var # Added for test
147149
}
148150

@@ -154,7 +156,7 @@ def self.load_env_config
154156
case config_key
155157
when :request_limit, :request_timeout
156158
env_config[config_key] = value.to_i
157-
when :use_catchall_route, :symbolize_payload, :normalize_headers
159+
when :use_catchall_route, :symbolize_payload, :normalize_headers, :symbolize_headers
158160
# Convert string to boolean
159161
env_config[config_key] = %w[true 1 yes on].include?(value.downcase)
160162
else

lib/hooks/plugins/handlers/base.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class Base
1515
# Process a webhook request
1616
#
1717
# @param payload [Hash, String] Parsed request body (JSON Hash) or raw string
18-
# @param headers [Hash<String, String>] HTTP headers
18+
# @param headers [Hash] HTTP headers (symbolized keys by default)
1919
# @param config [Hash] Merged endpoint configuration including opts section (symbolized keys)
2020
# @return [Hash, String, nil] Response body (will be auto-converted to JSON)
2121
# @raise [NotImplementedError] if not implemented by subclass

lib/hooks/utils/normalize.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,39 @@ def self.headers(headers)
5858
normalized
5959
end
6060

61+
# Symbolize header keys in a hash
62+
#
63+
# @param headers [Hash, #each] Headers hash or hash-like object
64+
# @return [Hash] Hash with symbolized keys (hyphens converted to underscores)
65+
#
66+
# @example Header symbolization
67+
# headers = { "content-type" => "application/json", "x-github-event" => "push" }
68+
# symbolized = Normalize.symbolize_headers(headers)
69+
# # => { content_type: "application/json", x_github_event: "push" }
70+
#
71+
# @example Handle various input types
72+
# Normalize.symbolize_headers(nil) # => nil
73+
# Normalize.symbolize_headers({}) # => {}
74+
def self.symbolize_headers(headers)
75+
# Handle nil input
76+
return nil if headers.nil?
77+
78+
# Fast path for non-enumerable inputs
79+
return {} unless headers.respond_to?(:each)
80+
81+
symbolized = {}
82+
83+
headers.each do |key, value|
84+
next if key.nil?
85+
86+
# Convert key to symbol, replacing hyphens with underscores
87+
symbolized_key = key.to_s.tr("-", "_").to_sym
88+
symbolized[symbolized_key] = value
89+
end
90+
91+
symbolized
92+
end
93+
6194
# Normalize a single HTTP header name
6295
#
6396
# @param header [String] Header name to normalize

lib/hooks/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
module Hooks
55
# Current version of the Hooks webhook framework
66
# @return [String] The version string following semantic versioning
7-
VERSION = "0.0.4".freeze
7+
VERSION = "0.0.5".freeze
88
end
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# frozen_string_literal: true
2+
3+
ENV["HOOKS_SILENCE_CONFIG_LOADER_MESSAGES"] = "true" # Silence config loader messages in tests
4+
5+
require_relative "../../lib/hooks"
6+
7+
describe "Header Symbolization Integration" do
8+
let(:config) do
9+
{
10+
symbolize_headers: true,
11+
normalize_headers: true
12+
}
13+
end
14+
15+
let(:headers) do
16+
{
17+
"Content-Type" => "application/json",
18+
"X-GitHub-Event" => "push",
19+
"User-Agent" => "test-agent",
20+
"Accept-Encoding" => "gzip, br"
21+
}
22+
end
23+
24+
context "when symbolize_headers is enabled (default)" do
25+
it "normalizes and symbolizes headers" do
26+
normalized_headers = config[:normalize_headers] ? Hooks::Utils::Normalize.headers(headers) : headers
27+
symbolized_headers = config[:symbolize_headers] ? Hooks::Utils::Normalize.symbolize_headers(normalized_headers) : normalized_headers
28+
29+
expect(symbolized_headers).to eq({
30+
content_type: "application/json",
31+
x_github_event: "push",
32+
user_agent: "test-agent",
33+
accept_encoding: "gzip, br"
34+
})
35+
end
36+
end
37+
38+
context "when symbolize_headers is disabled" do
39+
let(:config) do
40+
{
41+
symbolize_headers: false,
42+
normalize_headers: true
43+
}
44+
end
45+
46+
it "normalizes but does not symbolize headers" do
47+
normalized_headers = config[:normalize_headers] ? Hooks::Utils::Normalize.headers(headers) : headers
48+
symbolized_headers = config[:symbolize_headers] ? Hooks::Utils::Normalize.symbolize_headers(normalized_headers) : normalized_headers
49+
50+
expect(symbolized_headers).to eq({
51+
"content-type" => "application/json",
52+
"x-github-event" => "push",
53+
"user-agent" => "test-agent",
54+
"accept-encoding" => "gzip, br"
55+
})
56+
end
57+
end
58+
59+
context "when both symbolize_headers and normalize_headers are disabled" do
60+
let(:config) do
61+
{
62+
symbolize_headers: false,
63+
normalize_headers: false
64+
}
65+
end
66+
67+
it "passes headers through unchanged" do
68+
normalized_headers = config[:normalize_headers] ? Hooks::Utils::Normalize.headers(headers) : headers
69+
symbolized_headers = config[:symbolize_headers] ? Hooks::Utils::Normalize.symbolize_headers(normalized_headers) : normalized_headers
70+
71+
expect(symbolized_headers).to eq(headers)
72+
end
73+
end
74+
end

spec/unit/lib/hooks/core/config_loader_spec.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
endpoints_dir: "./config/endpoints",
2121
use_catchall_route: false,
2222
symbolize_payload: true,
23-
normalize_headers: true
23+
normalize_headers: true,
24+
symbolize_headers: true
2425
)
2526
end
2627
end
@@ -189,6 +190,7 @@
189190
ENV["HOOKS_USE_CATCHALL_ROUTE"] = "true"
190191
ENV["HOOKS_SYMBOLIZE_PAYLOAD"] = "1"
191192
ENV["HOOKS_NORMALIZE_HEADERS"] = "yes"
193+
ENV["HOOKS_SYMBOLIZE_HEADERS"] = "on"
192194
# Add a non-boolean var to ensure it's not misinterpreted
193195
ENV["HOOKS_SOME_STRING_VAR"] = "test_value"
194196

@@ -198,6 +200,7 @@
198200
expect(config[:use_catchall_route]).to be true
199201
expect(config[:symbolize_payload]).to be true
200202
expect(config[:normalize_headers]).to be true
203+
expect(config[:symbolize_headers]).to be true
201204
expect(config[:some_string_var]).to eq("test_value") # Check the string var
202205
end
203206
end
@@ -370,6 +373,13 @@
370373
handler: "ValidHandler"
371374
)
372375
end
376+
it "allows opt-out via environment variable" do
377+
ENV["HOOKS_SYMBOLIZE_HEADERS"] = "false"
378+
379+
config = described_class.load
380+
381+
expect(config[:symbolize_headers]).to be false
382+
end
373383
end
374384
end
375385

spec/unit/lib/hooks/plugins/utils/normalize_spec.rb

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,84 @@
117117
end
118118
end
119119
end
120+
121+
describe ".symbolize_headers" do
122+
context "when input is a hash of headers" do
123+
it "converts header keys to symbols and replaces hyphens with underscores" do
124+
headers = {
125+
"content-type" => "application/json",
126+
"x-github-event" => "push",
127+
"user-agent" => "test-agent",
128+
"authorization" => "Bearer token123"
129+
}
130+
131+
symbolized = described_class.symbolize_headers(headers)
132+
133+
expect(symbolized).to eq({
134+
content_type: "application/json",
135+
x_github_event: "push",
136+
user_agent: "test-agent",
137+
authorization: "Bearer token123"
138+
})
139+
end
140+
141+
it "handles mixed case and already symbolized keys" do
142+
headers = {
143+
"Content-Type" => "application/json",
144+
"X-GitHub-Event" => "push",
145+
:already_symbol => "value"
146+
}
147+
148+
symbolized = described_class.symbolize_headers(headers)
149+
150+
expect(symbolized).to eq({
151+
Content_Type: "application/json",
152+
X_GitHub_Event: "push",
153+
already_symbol: "value"
154+
})
155+
end
156+
157+
it "handles nil keys by skipping them" do
158+
headers = {
159+
"valid-header" => "value",
160+
nil => "should-be-skipped"
161+
}
162+
163+
symbolized = described_class.symbolize_headers(headers)
164+
165+
expect(symbolized).to eq({
166+
valid_header: "value"
167+
})
168+
end
169+
170+
it "handles nil input" do
171+
expect(described_class.symbolize_headers(nil)).to eq(nil)
172+
end
173+
174+
it "handles empty hash input" do
175+
expect(described_class.symbolize_headers({})).to eq({})
176+
end
177+
178+
it "handles non-enumerable input" do
179+
expect(described_class.symbolize_headers(123)).to eq({})
180+
expect(described_class.symbolize_headers(true)).to eq({})
181+
end
182+
183+
it "preserves header values unchanged" do
184+
headers = {
185+
"x-custom-header" => ["array", "values"],
186+
"numeric-header" => 123,
187+
"boolean-header" => true
188+
}
189+
190+
symbolized = described_class.symbolize_headers(headers)
191+
192+
expect(symbolized).to eq({
193+
x_custom_header: ["array", "values"],
194+
numeric_header: 123,
195+
boolean_header: true
196+
})
197+
end
198+
end
199+
end
120200
end

0 commit comments

Comments
 (0)