Skip to content

Commit 73cc4ed

Browse files
authored
Merge pull request #62 from github/copilot/fix-61
task: default to JSON in and JSON out
2 parents 645f06f + e7b67a5 commit 73cc4ed

File tree

13 files changed

+157
-56
lines changed

13 files changed

+157
-56
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.3.2)
4+
hooks-ruby (0.4.0)
55
dry-schema (~> 1.14, >= 1.14.1)
66
grape (~> 2.3)
77
puma (~> 6.6)

docs/configuration.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,15 @@ When set to `true`, enables a catch-all route that will handle requests to unkno
103103

104104
When set to `true`, normalizes incoming HTTP headers by lowercasing and trimming them. This ensures consistency in header names and values.
105105

106-
**Default:** `true`
106+
**Default:** `true`
107+
108+
### `default_format`
109+
110+
Sets the default response format when no specific format is requested.
111+
112+
**Default:** `json`
113+
**Valid values:** `json`, `txt`, `xml`, `any`
114+
**Example:** `json`
107115

108116
## Endpoint Options
109117

docs/handler_plugins.md

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,33 @@ This document provides in-depth information about handler plugins and how you ca
44

55
## Writing a Handler Plugin
66

7-
Handler plugins are Ruby classes that extend the `Hooks::Plugins::Handlers::Base` class. They are used to process webhook payloads and can do anything you want. They follow a very simple interface that allows you to define a `call` method that takes three parameters: `payload`, `headers`, and `config`. The `call` method should return a hash with the response data. The hash that this method returns will be sent back to the webhook sender as a JSON response.
7+
Handler plugins are Ruby classes that extend the `Hooks::Plugins::Handlers::Base` class. They are used to process webhook payloads and can do anything you want. They follow a very simple interface that allows you to define a `call` method that takes four parameters: `payload`, `headers`, `env`, and `config`.
8+
9+
**Important:** The `call` method should return a hash by default. Since the server now defaults to JSON format, any hash returned by the handler will be automatically converted to JSON with the correct `Content-Type: application/json` headers set by Grape. This ensures consistent API responses and proper JSON serialization.
810

911
- `payload`: The webhook payload, which can be a Hash or a String. This is the data that the webhook sender sends to your endpoint.
1012
- `headers`: A Hash of HTTP headers that were sent with the webhook request.
1113
- `env`: A modified Rack environment that contains a lot of context about the request. This includes information about the request method, path, query parameters, and more. See [`rack_env_builder.rb`](../lib/hooks/app/rack_env_builder.rb) for the complete list of available keys.
1214
- `config`: A Hash containing the endpoint configuration. This can include any additional settings or parameters that you want to use in your handler. Most of the time, this won't be used but sometimes endpoint configs add `opts` that can be useful for the handler.
1315

16+
The method should return a **hash** that will be automatically serialized to JSON format with appropriate headers. The server defaults to JSON format for both input and output processing.
17+
1418
```ruby
1519
# example file path: plugins/handlers/example.rb
1620
class Example < Hooks::Plugins::Handlers::Base
1721
# Process a webhook payload
1822
#
1923
# @param payload [Hash, String] webhook payload (pure JSON with string keys)
2024
# @param headers [Hash] HTTP headers (string keys, optionally normalized - default is normalized)
21-
# @param env [Hash] A modifed Rack environment that contains a lot of context about the request
25+
# @param env [Hash] A modified Rack environment that contains a lot of context about the request
2226
# @param config [Hash] Endpoint configuration
23-
# @return [Hash] Response data
27+
# @return [Hash] Response data (automatically converted to JSON)
2428
def call(payload:, headers:, env:, config:)
29+
# Return a hash - it will be automatically converted to JSON with proper headers
2530
return {
26-
status: "success"
31+
status: "success",
32+
message: "webhook processed successfully",
33+
timestamp: Time.now.iso8601
2734
}
2835
end
2936
end
@@ -43,6 +50,31 @@ It should be noted that the `handler:` key in the endpoint configuration file sh
4350
- `MyCustomHandler` -> `my_custom_handler`
4451
- `Cool2Handler` -> `cool_2_handler`
4552

53+
## Default JSON Format
54+
55+
By default, the Hooks server uses JSON format for both input and output processing. This means:
56+
57+
- **Input**: Webhook payloads are parsed as JSON and passed to handlers as Ruby hashes
58+
- **Output**: Handler return values (hashes) are automatically converted to JSON responses with `Content-Type: application/json` headers
59+
- **Error Responses**: Authentication failures and handler errors return structured JSON responses
60+
61+
**Best Practice**: Always return a hash from your handler's `call` method. The hash will be automatically serialized to JSON and sent to the webhook sender with proper headers. This ensures consistent API responses and proper JSON formatting.
62+
63+
Example response format:
64+
65+
```json
66+
{
67+
"status": "success",
68+
"message": "webhook processed successfully",
69+
"data": {
70+
"processed_at": "2023-10-01T12:34:56Z",
71+
"items_processed": 5
72+
}
73+
}
74+
```
75+
76+
> **Note**: The JSON format behavior can be configured using the `default_format` option in your global configuration. See the [Configuration documentation](./configuration.md) for more details.
77+
4678
### `payload` Parameter
4779

4880
The `payload` parameter can be a Hash or a String. If the payload is a String, it will be parsed as JSON. If it is a Hash, it will be passed directly to the handler. The payload can contain any data that the webhook sender wants to send.
@@ -159,6 +191,8 @@ The `log.debug`, `log.info`, `log.warn`, and `log.error` methods are available i
159191

160192
All handler plugins have access to the `error!` method, which is used to raise an error with a specific message and HTTP status code. This is useful for returning error responses to the webhook sender.
161193

194+
When using `error!` with the default JSON format, both hash and string responses are handled appropriately:
195+
162196
```ruby
163197
class Example < Hooks::Plugins::Handlers::Base
164198
# Example webhook handler
@@ -167,11 +201,12 @@ class Example < Hooks::Plugins::Handlers::Base
167201
# @param headers [Hash<String, String>] HTTP headers
168202
# @param env [Hash] A modified Rack environment that contains a lot of context about the request
169203
# @param config [Hash] Endpoint configuration
170-
# @return [Hash] Response data
204+
# @return [Hash] Response data (automatically converted to JSON)
171205
def call(payload:, headers:, env:, config:)
172206
173207
if payload.nil? || payload.empty?
174208
log.error("Payload is empty or nil")
209+
# String errors are JSON-encoded with default format
175210
error!("Payload cannot be empty or nil", 400)
176211
end
177212
@@ -182,21 +217,22 @@ class Example < Hooks::Plugins::Handlers::Base
182217
end
183218
```
184219

185-
You can also use the `error!` method to return a JSON response as well:
220+
**Recommended approach**: Use hash-based error responses for consistent JSON structure:
186221

187222
```ruby
188223
class Example < Hooks::Plugins::Handlers::Base
189224
def call(payload:, headers:, env:, config:)
190225
191226
if payload.nil? || payload.empty?
192227
log.error("Payload is empty or nil")
228+
# Hash errors are automatically converted to JSON
193229
error!({
194230
error: "payload_empty",
195231
message: "the payload cannot be empty or nil",
196232
success: false,
197233
custom_value: "some_custom_value",
198234
request_id: env["hooks.request_id"]
199-
}, 500)
235+
}, 400)
200236
end
201237
202238
return {
@@ -206,6 +242,18 @@ class Example < Hooks::Plugins::Handlers::Base
206242
end
207243
```
208244

245+
This will return a properly formatted JSON error response:
246+
247+
```json
248+
{
249+
"error": "payload_empty",
250+
"message": "the payload cannot be empty or nil",
251+
"success": false,
252+
"custom_value": "some_custom_value",
253+
"request_id": "uuid-here"
254+
}
255+
```
256+
209257
### `#Retryable.with_context(:default)`
210258

211259
This method uses a default `Retryable` context to handle retries. It is used to wrap the execution of a block of code that may need to be retried in case of failure.
@@ -220,7 +268,7 @@ class Example < Hooks::Plugins::Handlers::Base
220268
# @param headers [Hash<String, String>] HTTP headers
221269
# @param env [Hash] A modified Rack environment that contains a lot of context about the request
222270
# @param config [Hash] Endpoint configuration
223-
# @return [Hash] Response data
271+
# @return [Hash] Response data (automatically converted to JSON)
224272
def call(payload:, headers:, env:, config:)
225273
result = Retryable.with_context(:default) do
226274
some_operation_that_might_fail()
@@ -229,7 +277,9 @@ class Example < Hooks::Plugins::Handlers::Base
229277
log.debug("operation result: #{result}")
230278
231279
return {
232-
status: "success"
280+
status: "success",
281+
operation_result: result,
282+
processed_at: Time.now.iso8601
233283
}
234284
end
235285
end

lib/hooks/app/api.rb

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,7 @@ def self.create(config:, endpoints:, log:)
3939
content_type :xml, "application/xml"
4040
content_type :any, "*/*"
4141

42-
format :txt # TODO: make this configurable via config[:format] (defaults to :json in the future)
43-
default_format :txt # TODO: make this configurable via config[:default_format] (defaults to :json in the future)
42+
default_format config[:default_format] || :json
4443
end
4544

4645
api_class.class_eval do
@@ -118,22 +117,21 @@ def self.create(config:, endpoints:, log:)
118117
log.info("successfully processed webhook event with handler: #{handler_class_name}")
119118
log.debug("processing duration: #{Time.now - start_time}s")
120119
status 200
121-
content_type "application/json"
122-
response.to_json
120+
response
123121
rescue Hooks::Plugins::Handlers::Error => e
124122
# Handler called error! method - immediately return error response and exit the request
125123
log.debug("handler #{handler_class_name} called `error!` method")
126124

127-
error_response = nil
128-
129125
status e.status
130126
case e.body
131127
when String
128+
# if error! was called with a string, we assume it's a simple text error
129+
# example: error!("simple text error", 400) -> should return a plain text response
132130
content_type "text/plain"
133131
error_response = e.body
134132
else
135-
content_type "application/json"
136-
error_response = e.body.to_json
133+
# Let Grape handle JSON conversion with the default format
134+
error_response = e.body
137135
end
138136

139137
return error_response
@@ -164,8 +162,7 @@ def self.create(config:, endpoints:, log:)
164162
error_response[:handler] = handler_class_name unless config[:production]
165163

166164
status determine_error_code(e)
167-
content_type "application/json"
168-
error_response.to_json
165+
error_response
169166
end
170167
end
171168
end

lib/hooks/app/endpoints/catchall.rb

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ module App
1717
class CatchallEndpoint < Grape::API
1818
include Hooks::App::Helpers
1919

20+
# Set up content types and default format to JSON to match main API
21+
content_type :json, "application/json"
22+
content_type :txt, "text/plain"
23+
content_type :xml, "application/xml"
24+
content_type :any, "*/*"
25+
default_format :json
26+
2027
def self.mount_path(config)
2128
# :nocov:
2229
"#{config[:root_path]}/*path"
@@ -81,8 +88,7 @@ def self.route_block(captured_config, captured_logger)
8188
log.info("successfully processed webhook event with handler: #{handler_class_name}")
8289
log.debug("processing duration: #{Time.now - start_time}s")
8390
status 200
84-
content_type "application/json"
85-
response.to_json
91+
response
8692
rescue StandardError => e
8793
err_msg = "Error processing webhook event with handler: #{handler_class_name} - #{e.message} " \
8894
"- request_id: #{request_id} - path: #{full_path} - method: #{http_method} - " \
@@ -102,8 +108,7 @@ def self.route_block(captured_config, captured_logger)
102108
error_response[:handler] = handler_class_name unless config[:production]
103109

104110
status determine_error_code(e)
105-
content_type "application/json"
106-
error_response.to_json
111+
error_response
107112
end
108113
end
109114
end

lib/hooks/app/endpoints/health.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@
66
module Hooks
77
module App
88
class HealthEndpoint < Grape::API
9+
# Set up content types and default format to JSON
10+
content_type :json, "application/json"
11+
content_type :txt, "text/plain"
12+
content_type :xml, "application/xml"
13+
content_type :any, "*/*"
14+
default_format :json
15+
916
get do
10-
content_type "application/json"
1117
{
1218
status: "healthy",
1319
timestamp: Time.now.utc.iso8601,
1420
version: Hooks::VERSION,
1521
uptime_seconds: (Time.now - Hooks::App::API.server_start_time).to_i
16-
}.to_json
22+
}
1723
end
1824
end
1925
end

lib/hooks/app/endpoints/version.rb

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@
66
module Hooks
77
module App
88
class VersionEndpoint < Grape::API
9+
# Set up content types and default format to JSON
10+
content_type :json, "application/json"
11+
content_type :txt, "text/plain"
12+
content_type :xml, "application/xml"
13+
content_type :any, "*/*"
14+
default_format :json
15+
916
get do
10-
content_type "application/json"
1117
{
1218
version: Hooks::VERSION,
1319
timestamp: Time.now.utc.iso8601
14-
}.to_json
20+
}
1521
end
1622
end
1723
end

lib/hooks/core/config_loader.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ class ConfigLoader
2020
production: true,
2121
endpoints_dir: "./config/endpoints",
2222
use_catchall_route: false,
23-
normalize_headers: true
23+
normalize_headers: true,
24+
default_format: :json
2425
}.freeze
2526

2627
SILENCE_CONFIG_LOADER_MESSAGES = ENV.fetch(
@@ -141,6 +142,7 @@ def self.load_env_config
141142
"HOOKS_ENDPOINTS_DIR" => :endpoints_dir,
142143
"HOOKS_USE_CATCHALL_ROUTE" => :use_catchall_route,
143144
"HOOKS_NORMALIZE_HEADERS" => :normalize_headers,
145+
"HOOKS_DEFAULT_FORMAT" => :default_format,
144146
"HOOKS_SOME_STRING_VAR" => :some_string_var # Added for test
145147
}
146148

@@ -155,6 +157,9 @@ def self.load_env_config
155157
when :use_catchall_route, :normalize_headers
156158
# Convert string to boolean
157159
env_config[config_key] = %w[true 1 yes on].include?(value.downcase)
160+
when :default_format
161+
# Convert string to symbol
162+
env_config[config_key] = value.to_sym
158163
else
159164
env_config[config_key] = value
160165
end

lib/hooks/core/config_validator.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class ValidationError < StandardError; end
2727
optional(:endpoints_dir).filled(:string)
2828
optional(:use_catchall_route).filled(:bool)
2929
optional(:normalize_headers).filled(:bool)
30+
optional(:default_format).filled(:symbol, included_in?: %i[json txt xml any])
3031

3132
optional(:ip_filtering).hash do
3233
optional(:ip_header).filled(:string)

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.3.2".freeze
7+
VERSION = "0.4.0".freeze
88
end

0 commit comments

Comments
 (0)