Skip to content

Commit 80d59a7

Browse files
authored
Merge pull request #50 from github/copilot/fix-49
Implement Grape's `error!` method for handler subclasses
2 parents 334877f + c9f1039 commit 80d59a7

File tree

10 files changed

+236
-23
lines changed

10 files changed

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

docs/handler_plugins.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,57 @@ This section goes into details on the built-in features that exist in all handle
140140

141141
The `log.debug`, `log.info`, `log.warn`, and `log.error` methods are available in all handler plugins. They are used to log messages at different levels of severity.
142142

143+
### `#error!`
144+
145+
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.
146+
147+
```ruby
148+
class Example < Hooks::Plugins::Handlers::Base
149+
# Example webhook handler
150+
#
151+
# @param payload [Hash, String] Webhook payload
152+
# @param headers [Hash<String, String>] HTTP headers
153+
# @param env [Hash] A modified Rack environment that contains a lot of context about the request
154+
# @param config [Hash] Endpoint configuration
155+
# @return [Hash] Response data
156+
def call(payload:, headers:, env:, config:)
157+
158+
if payload.nil? || payload.empty?
159+
log.error("Payload is empty or nil")
160+
error!("Payload cannot be empty or nil", 400)
161+
end
162+
163+
return {
164+
status: "success"
165+
}
166+
end
167+
end
168+
```
169+
170+
You can also use the `error!` method to return a JSON response as well:
171+
172+
```ruby
173+
class Example < Hooks::Plugins::Handlers::Base
174+
def call(payload:, headers:, env:, config:)
175+
176+
if payload.nil? || payload.empty?
177+
log.error("Payload is empty or nil")
178+
error!({
179+
error: "payload_empty",
180+
message: "the payload cannot be empty or nil",
181+
success: false,
182+
custom_value: "some_custom_value",
183+
request_id: env["hooks.request_id"]
184+
}, 500)
185+
end
186+
187+
return {
188+
status: "success"
189+
}
190+
end
191+
end
192+
```
193+
143194
### `#Retryable.with_context(:default)`
144195

145196
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.
@@ -172,3 +223,7 @@ end
172223
> If `Retryable.with_context(:default)` fails after all retries, it will re-raise the last exception encountered.
173224
174225
See the source code at `lib/hooks/utils/retry.rb` for more details on how `Retryable.with_context(:default)` works.
226+
227+
### `#failbot` and `#stats`
228+
229+
The `failbot` and `stats` methods are available in all handler plugins. They are used to report errors and send statistics, respectively. These are custom methods and you can learn more about them in the [Instrumentation Plugins](instrument_plugins.md) documentation.

lib/hooks/app/api.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
require_relative "auth/auth"
88
require_relative "rack_env_builder"
99
require_relative "../plugins/handlers/base"
10+
require_relative "../plugins/handlers/error"
1011
require_relative "../plugins/handlers/default"
1112
require_relative "../core/logger_factory"
1213
require_relative "../core/log"
@@ -110,6 +111,23 @@ def self.create(config:, endpoints:, log:)
110111
status 200
111112
content_type "application/json"
112113
response.to_json
114+
rescue Hooks::Plugins::Handlers::Error => e
115+
# Handler called error! method - immediately return error response and exit the request
116+
log.debug("handler #{handler_class_name} called `error!` method")
117+
118+
error_response = nil
119+
120+
status e.status
121+
case e.body
122+
when String
123+
content_type "text/plain"
124+
error_response = e.body
125+
else
126+
content_type "application/json"
127+
error_response = e.body.to_json
128+
end
129+
130+
return error_response
113131
rescue StandardError => e
114132
err_msg = "Error processing webhook event with handler: #{handler_class_name} - #{e.message} " \
115133
"- request_id: #{request_id} - path: #{full_path} - method: #{http_method} - " \

lib/hooks/plugins/handlers/base.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require_relative "../../core/global_components"
44
require_relative "../../core/component_access"
5+
require_relative "error"
56

67
module Hooks
78
module Plugins
@@ -23,6 +24,28 @@ class Base
2324
def call(payload:, headers:, env:, config:)
2425
raise NotImplementedError, "Handler must implement #call method"
2526
end
27+
28+
# Terminate request processing with a custom error response
29+
#
30+
# This method provides the same interface as Grape's `error!` method,
31+
# allowing handlers to immediately stop processing and return a specific
32+
# error response to the client.
33+
#
34+
# @param body [Object] The error body/data to return to the client
35+
# @param status [Integer] The HTTP status code to return (default: 500)
36+
# @raise [Hooks::Plugins::Handlers::Error] Always raises to terminate processing
37+
#
38+
# @example Return a custom error with status 400
39+
# error!({ error: "validation_failed", message: "Invalid payload" }, 400)
40+
#
41+
# @example Return a simple string error with status 401
42+
# error!("Unauthorized", 401)
43+
#
44+
# @example Return an error with default 500 status
45+
# error!({ error: "internal_error", message: "Something went wrong" })
46+
def error!(body, status = 500)
47+
raise Error.new(body, status)
48+
end
2649
end
2750
end
2851
end

lib/hooks/plugins/handlers/error.rb

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# frozen_string_literal: true
2+
3+
module Hooks
4+
module Plugins
5+
module Handlers
6+
# Custom exception class for handler errors
7+
#
8+
# This exception is used when handlers call the `error!` method to
9+
# immediately terminate request processing and return a specific error response.
10+
# It carries the error details back to the Grape API context where it can be
11+
# properly formatted and returned to the client.
12+
#
13+
# @example Usage in handler
14+
# error!({ error: "validation_failed", message: "Invalid payload" }, 400)
15+
#
16+
# @see Hooks::Plugins::Handlers::Base#error!
17+
class Error < StandardError
18+
# @return [Object] The error body/data to return to the client
19+
attr_reader :body
20+
21+
# @return [Integer] The HTTP status code to return
22+
attr_reader :status
23+
24+
# Initialize a new handler error
25+
#
26+
# @param body [Object] The error body/data to return to the client
27+
# @param status [Integer] The HTTP status code to return (default: 500)
28+
def initialize(body, status = 500)
29+
@body = body
30+
@status = status.to_i
31+
super("Handler error: #{status} - #{body}")
32+
end
33+
end
34+
end
35+
end
36+
end

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

spec/acceptance/acceptance_tests.rb

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -496,26 +496,28 @@ def expired_unix_timestamp(seconds_ago = 600)
496496
end
497497

498498
it "sends a POST request to the /webhooks/boomtown_with_error endpoint and it explodes" do
499-
# TODO: Fix this acceptance test - the current error looks like this:
500-
# 1) Hooks endpoints boomtown_with_error sends a POST request to the /webhooks/boomtown_with_error endpoint and it explodes
501-
# Failure/Error: expect(response.body).to include(expected_body_content) if expected_body_content
502-
#expected "{\"error\":\"server_error\",\"message\":\"undefined method 'error!' for an instance of BoomtownWithE...thread_pool.rb:167:in 'block in #Puma::ThreadPool#spawn_thread'\",\"handler\":\"BoomtownWithError\"}" to include "the payload triggered a boomtown error"
503-
# ./spec/acceptance/acceptance_tests.rb:28:in 'RSpec::ExampleGroups::Hooks#expect_response'
504-
# ./spec/acceptance/acceptance_tests.rb:501:in 'block (4 levels) in <top (required)>'
505-
506-
# payload = { boom: true }.to_json
507-
# response = make_request(:post, "/webhooks/boomtown_with_error", payload, json_headers)
508-
# expect_response(response, Net::HTTPInternalServerError, "the payload triggered a boomtown error")
509-
510-
# body = parse_json_response(response)
511-
# expect(body["error"]).to eq("server_error")
512-
# expect(body["message"]).to eq("the payload triggered a boomtown error")
513-
# expect(body).to have_key("backtrace")
514-
# expect(body["backtrace"]).to be_a(String)
515-
# expect(body).to have_key("request_id")
516-
# expect(body["request_id"]).to be_a(String)
517-
# expect(body).to have_key("handler")
518-
# expect(body["handler"]).to eq("BoomtownWithError")
499+
payload = { boom: true }.to_json
500+
response = make_request(:post, "/webhooks/boomtown_with_error", payload, json_headers)
501+
expect_response(response, Net::HTTPInternalServerError, "the payload triggered a boomtown error")
502+
503+
body = parse_json_response(response)
504+
expect(body["error"]).to eq("boomtown_with_error")
505+
expect(body["message"]).to eq("the payload triggered a boomtown error")
506+
expect(body).to have_key("request_id")
507+
expect(body["request_id"]).to be_a(String)
508+
expect(body["foo"]).to eq("bar")
509+
expect(body["truthy"]).to eq(true)
510+
end
511+
512+
it "sends a POST request to the /webhooks/boomtown_with_error endpoint and it explodes with a simple text error" do
513+
payload = { boom_simple_text: true }.to_json
514+
response = make_request(:post, "/webhooks/boomtown_with_error", payload, json_headers)
515+
expect_response(response, Net::HTTPInternalServerError, "boomtown_with_error: the payload triggered a simple text boomtown error")
516+
517+
body = response.body
518+
expect(body).to eq("boomtown_with_error: the payload triggered a simple text boomtown error")
519+
expect(response.content_type).to eq("text/plain")
520+
expect(response.code).to eq("500")
519521
end
520522
end
521523
end

spec/acceptance/plugins/handlers/boomtown_with_error.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,25 @@ def call(payload:, headers:, env:, config:)
66
if payload["boom"] == true
77
log.error("boomtown error triggered by payload: #{payload.inspect} - request_id: #{env["hooks.request_id"]}")
88

9-
# TODO: Get Grape's `error!` method to work with this
9+
# Use Grape's `error!` method to return a custom error response
1010
error!({
1111
error: "boomtown_with_error",
1212
message: "the payload triggered a boomtown error",
13+
foo: "bar",
14+
truthy: true,
15+
payload:,
16+
headers:,
1317
request_id: env["hooks.request_id"]
1418
}, 500)
1519
end
1620

21+
if payload["boom_simple_text"] == true
22+
log.error("boomtown simple text error triggered by payload: #{payload.inspect} - request_id: #{env["hooks.request_id"]}")
23+
24+
# Use Grape's `error!` method to return a simple text error response
25+
error!("boomtown_with_error: the payload triggered a simple text boomtown error", 500)
26+
end
27+
1728
return { status: "ok" }
1829
end
1930
end

spec/unit/lib/hooks/handlers/base_spec.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,4 +250,42 @@ def report(error_or_message, context = {})
250250
end
251251
end
252252
end
253+
254+
describe "#error!" do
255+
let(:handler) { described_class.new }
256+
257+
it "raises a handler error with default status 500" do
258+
expect {
259+
handler.error!("Something went wrong")
260+
}.to raise_error(Hooks::Plugins::Handlers::Error) do |error|
261+
expect(error.body).to eq("Something went wrong")
262+
expect(error.status).to eq(500)
263+
end
264+
end
265+
266+
it "raises a handler error with custom status" do
267+
expect {
268+
handler.error!({ error: "validation_failed", message: "Invalid input" }, 400)
269+
}.to raise_error(Hooks::Plugins::Handlers::Error) do |error|
270+
expect(error.body).to eq({ error: "validation_failed", message: "Invalid input" })
271+
expect(error.status).to eq(400)
272+
end
273+
end
274+
275+
it "can be called from subclasses" do
276+
test_handler = Class.new(described_class) do
277+
def call(payload:, headers:, env:, config:)
278+
error!("Custom error from subclass", 422)
279+
end
280+
end
281+
282+
handler = test_handler.new
283+
expect {
284+
handler.call(payload: {}, headers: {}, env: {}, config: {})
285+
}.to raise_error(Hooks::Plugins::Handlers::Error) do |error|
286+
expect(error.body).to eq("Custom error from subclass")
287+
expect(error.status).to eq(422)
288+
end
289+
end
290+
end
253291
end
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
describe Hooks::Plugins::Handlers::Error do
4+
describe "#initialize" do
5+
it "creates an error with default status 500" do
6+
error = described_class.new("test error")
7+
expect(error.body).to eq("test error")
8+
expect(error.status).to eq(500)
9+
expect(error.message).to eq("Handler error: 500 - test error")
10+
end
11+
12+
it "creates an error with custom status" do
13+
error = described_class.new({ error: "validation_failed" }, 400)
14+
expect(error.body).to eq({ error: "validation_failed" })
15+
expect(error.status).to eq(400)
16+
expect(error.message).to match(/^Handler error: 400 - \{.*error.*validation_failed.*\}$/)
17+
end
18+
19+
it "converts status to integer" do
20+
error = described_class.new("test", "404")
21+
expect(error.status).to eq(404)
22+
end
23+
end
24+
25+
describe "inheritance" do
26+
it "inherits from StandardError" do
27+
expect(described_class.ancestors).to include(StandardError)
28+
end
29+
end
30+
end

0 commit comments

Comments
 (0)