hooksmith 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +295 -64
- data/lib/hooksmith/config/provider.rb +25 -4
- data/lib/hooksmith/configuration.rb +46 -7
- data/lib/hooksmith/dispatcher.rb +71 -34
- data/lib/hooksmith/errors.rb +240 -0
- data/lib/hooksmith/idempotency.rb +107 -0
- data/lib/hooksmith/instrumentation.rb +112 -0
- data/lib/hooksmith/jobs/dispatcher_job.rb +63 -0
- data/lib/hooksmith/rails/webhooks_controller.rb +152 -0
- data/lib/hooksmith/request.rb +110 -0
- data/lib/hooksmith/verifiers/base.rb +79 -0
- data/lib/hooksmith/verifiers/bearer_token.rb +79 -0
- data/lib/hooksmith/verifiers/hmac.rb +184 -0
- data/lib/hooksmith/version.rb +1 -1
- data/lib/hooksmith.rb +51 -1
- metadata +32 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1c87efd7d2887fc38d26d7615fbb913efaa086f09b0753b84479d41f87c4a4fd
|
|
4
|
+
data.tar.gz: 395f26ad9af550a7d64596edd3f23a486db879f8fc71aa9e2997a4e468eb785e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b2d5953050726bec3601f37656b51a8d98f242e119058df1bca090568e9d99fd47dcde8d0788a4077dfc2dbe27f426a06d70ce85546edd36f0bbb80f91c1c237
|
|
7
|
+
data.tar.gz: 8e20347d959d17c8c03da4e969e06c5158ad9c083ae675aaaeeee86fb7cc8051ea11cdc780922d4c504b0775f2d754309aa97a6f0e587678dd8a96abe46bae89
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,57 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.0.0] - 2025-12-25
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Request Verification Interface** - Built-in verifiers for HMAC and Bearer token authentication
|
|
8
|
+
- `Hooksmith::Verifiers::Hmac` for HMAC-based signature verification
|
|
9
|
+
- `Hooksmith::Verifiers::BearerToken` for Bearer token authentication
|
|
10
|
+
- `Hooksmith::Verifiers::Base` for creating custom verifiers
|
|
11
|
+
- `Hooksmith::Request` wrapper for accessing headers and body
|
|
12
|
+
|
|
13
|
+
- **Error Taxonomy** - Explicit error classes for different failure modes
|
|
14
|
+
- `Hooksmith::Error` base class for all Hooksmith errors
|
|
15
|
+
- `Hooksmith::VerificationError` for signature verification failures
|
|
16
|
+
|
|
17
|
+
- **Idempotency Support** - Prevent duplicate webhook processing
|
|
18
|
+
- Configurable idempotency key extraction per provider
|
|
19
|
+
- Pre-built extractors for Stripe, GitHub, and generic webhooks
|
|
20
|
+
- `Hooksmith::Idempotency.extract_key` and `already_processed?` methods
|
|
21
|
+
|
|
22
|
+
- **ActiveJob Integration** - Process webhooks asynchronously
|
|
23
|
+
- `Hooksmith::Jobs::DispatcherJob` for background processing
|
|
24
|
+
- Automatic idempotency checking (can be skipped)
|
|
25
|
+
|
|
26
|
+
- **Instrumentation** - ActiveSupport::Notifications hooks for observability
|
|
27
|
+
- `dispatch.hooksmith` event wrapping the entire dispatch flow
|
|
28
|
+
- `process.hooksmith` event wrapping processor execution
|
|
29
|
+
- `no_processor.hooksmith` event when no processor matches
|
|
30
|
+
- `multiple_processors.hooksmith` event when multiple processors match
|
|
31
|
+
- `error.hooksmith` event when an error occurs
|
|
32
|
+
|
|
33
|
+
- **Rails Controller Concern** - Standardized webhook handling
|
|
34
|
+
- `Hooksmith::Rails::WebhooksController` concern
|
|
35
|
+
- `handle_webhook` for synchronous processing
|
|
36
|
+
- `handle_webhook_async` for background processing
|
|
37
|
+
- Automatic CSRF protection skip
|
|
38
|
+
- Optional signature verification integration
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Internal storage now uses strings instead of symbols to prevent Symbol DoS attacks
|
|
43
|
+
- `MultipleProcessorsError` no longer includes full payload in error message to prevent PII exposure
|
|
44
|
+
|
|
45
|
+
### Security
|
|
46
|
+
|
|
47
|
+
- Fixed Symbol DoS vulnerability in Dispatcher (provider/event were converted to symbols from untrusted input)
|
|
48
|
+
- Fixed PII exposure in `MultipleProcessorsError` (full payload was included in error message)
|
|
49
|
+
|
|
50
|
+
## [0.2.0] - 2025-03-15
|
|
51
|
+
|
|
52
|
+
- Added event store for persisting webhook events
|
|
53
|
+
- Added configurable mapper for event persistence
|
|
54
|
+
|
|
3
55
|
## [0.1.0] - 2025-03-12
|
|
4
56
|
|
|
5
57
|
- Initial release
|
data/README.md
CHANGED
|
@@ -6,16 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
- **DSL for Registration:** Group processors by provider and event.
|
|
8
8
|
- **Flexible Dispatcher:** Dynamically selects the appropriate processor based on payload conditions.
|
|
9
|
+
- **Request Verification:** Built-in support for HMAC and Bearer token authentication.
|
|
10
|
+
- **Idempotency Support:** Prevent duplicate webhook processing with configurable key extraction.
|
|
11
|
+
- **ActiveJob Integration:** Process webhooks asynchronously with automatic idempotency checks.
|
|
12
|
+
- **Instrumentation:** ActiveSupport::Notifications hooks for observability.
|
|
13
|
+
- **Rails Controller Concern:** Standardized webhook handling with consistent response codes.
|
|
9
14
|
- **Rails Integration:** Automatically configures with Rails using a Railtie.
|
|
10
15
|
- **Lightweight Logging:** Built-in logging that can be switched to `Rails.logger` when in a Rails environment.
|
|
11
|
-
- **Tested with Minitest:**
|
|
16
|
+
- **Tested with Minitest:** Comprehensive test coverage for robust behavior.
|
|
12
17
|
|
|
13
18
|
## Installation
|
|
14
19
|
|
|
15
20
|
Add this line to your application's Gemfile:
|
|
16
21
|
|
|
17
22
|
```ruby
|
|
18
|
-
gem 'hooksmith', '~>
|
|
23
|
+
gem 'hooksmith', '~> 1.0'
|
|
19
24
|
```
|
|
20
25
|
|
|
21
26
|
Then execute:
|
|
@@ -29,30 +34,244 @@ Or install it yourself as:
|
|
|
29
34
|
gem install hooksmith
|
|
30
35
|
```
|
|
31
36
|
|
|
32
|
-
##
|
|
37
|
+
## Quick Start
|
|
33
38
|
|
|
34
|
-
###
|
|
39
|
+
### 1. Configure Providers and Processors
|
|
35
40
|
|
|
36
41
|
Configure your webhook processors in an initializer (e.g., `config/initializers/hooksmith.rb`):
|
|
37
42
|
|
|
38
43
|
```ruby
|
|
39
44
|
Hooksmith.configure do |config|
|
|
40
45
|
config.provider(:stripe) do |stripe|
|
|
41
|
-
stripe.register(:charge_succeeded, 'Stripe::
|
|
42
|
-
stripe.register(:
|
|
46
|
+
stripe.register(:charge_succeeded, 'Stripe::ChargeSucceededProcessor')
|
|
47
|
+
stripe.register(:payment_failed, 'Stripe::PaymentFailedProcessor')
|
|
43
48
|
end
|
|
44
49
|
|
|
45
|
-
config.provider(:
|
|
46
|
-
|
|
50
|
+
config.provider(:github) do |github|
|
|
51
|
+
github.register(:push, 'Github::PushProcessor')
|
|
52
|
+
github.register(:pull_request, 'Github::PullRequestProcessor')
|
|
47
53
|
end
|
|
48
54
|
end
|
|
49
55
|
```
|
|
50
56
|
|
|
51
|
-
###
|
|
57
|
+
### 2. Create a Processor
|
|
52
58
|
|
|
53
|
-
|
|
59
|
+
Create a processor by inheriting from `Hooksmith::Processor::Base`:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
class Stripe::ChargeSucceededProcessor < Hooksmith::Processor::Base
|
|
63
|
+
def can_handle?(payload)
|
|
64
|
+
payload.dig('data', 'object', 'status') == 'succeeded'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def process!
|
|
68
|
+
charge_id = payload.dig('data', 'object', 'id')
|
|
69
|
+
Payment.find_by(stripe_charge_id: charge_id)&.mark_as_paid!
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 3. Handle Webhooks in Your Controller
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
class WebhooksController < ApplicationController
|
|
78
|
+
skip_before_action :verify_authenticity_token
|
|
79
|
+
|
|
80
|
+
def stripe
|
|
81
|
+
Hooksmith::Dispatcher.new(
|
|
82
|
+
provider: :stripe,
|
|
83
|
+
event: params[:type],
|
|
84
|
+
payload: params.to_unsafe_h
|
|
85
|
+
).run!
|
|
86
|
+
|
|
87
|
+
head :ok
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
head :internal_server_error
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Request Verification
|
|
95
|
+
|
|
96
|
+
Hooksmith provides built-in verifiers for common authentication patterns.
|
|
97
|
+
|
|
98
|
+
### HMAC Verification
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
Hooksmith.configure do |config|
|
|
102
|
+
config.provider(:stripe) do |stripe|
|
|
103
|
+
stripe.verifier = Hooksmith::Verifiers::Hmac.new(
|
|
104
|
+
secret: ENV['STRIPE_WEBHOOK_SECRET'],
|
|
105
|
+
header: 'Stripe-Signature',
|
|
106
|
+
algorithm: :sha256
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Bearer Token Verification
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
Hooksmith.configure do |config|
|
|
116
|
+
config.provider(:internal) do |internal|
|
|
117
|
+
internal.verifier = Hooksmith::Verifiers::BearerToken.new(
|
|
118
|
+
token: ENV['WEBHOOK_SECRET_TOKEN'],
|
|
119
|
+
header: 'Authorization'
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Custom Verifier
|
|
126
|
+
|
|
127
|
+
Create your own verifier by inheriting from `Hooksmith::Verifiers::Base`:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class MyCustomVerifier < Hooksmith::Verifiers::Base
|
|
131
|
+
def verify!(request)
|
|
132
|
+
signature = request.headers['X-Custom-Signature']
|
|
133
|
+
expected = compute_signature(request.body)
|
|
134
|
+
|
|
135
|
+
raise Hooksmith::VerificationError, 'Invalid signature' unless secure_compare(signature, expected)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
54
139
|
|
|
55
|
-
|
|
140
|
+
def compute_signature(body)
|
|
141
|
+
OpenSSL::HMAC.hexdigest('SHA256', @secret, body)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Idempotency Support
|
|
147
|
+
|
|
148
|
+
Prevent duplicate webhook processing by configuring idempotency key extraction:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
Hooksmith.configure do |config|
|
|
152
|
+
config.provider(:stripe) do |stripe|
|
|
153
|
+
stripe.idempotency_key = ->(payload) { payload.dig('id') }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
config.provider(:github) do |github|
|
|
157
|
+
github.idempotency_key = Hooksmith::Idempotency::GITHUB
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### Pre-built Extractors
|
|
163
|
+
|
|
164
|
+
Hooksmith includes pre-built extractors for common providers:
|
|
165
|
+
|
|
166
|
+
- `Hooksmith::Idempotency::STRIPE` - Extracts from `id` field
|
|
167
|
+
- `Hooksmith::Idempotency::GITHUB` - Extracts from `X-GitHub-Delivery` header
|
|
168
|
+
- `Hooksmith::Idempotency::GENERIC` - Extracts from `id`, `event_id`, or `request_id`
|
|
169
|
+
|
|
170
|
+
### Checking for Duplicates
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
key = Hooksmith::Idempotency.extract_key(provider: 'stripe', payload: params)
|
|
174
|
+
if Hooksmith::Idempotency.already_processed?(provider: 'stripe', key: key)
|
|
175
|
+
head :ok
|
|
176
|
+
return
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## ActiveJob Integration
|
|
181
|
+
|
|
182
|
+
Process webhooks asynchronously with automatic idempotency checking:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
class WebhooksController < ApplicationController
|
|
186
|
+
def stripe
|
|
187
|
+
Hooksmith::Jobs::DispatcherJob.perform_later(
|
|
188
|
+
provider: 'stripe',
|
|
189
|
+
event: params[:type],
|
|
190
|
+
payload: params.to_unsafe_h
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
head :ok
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Skip idempotency checking if needed:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
Hooksmith::Jobs::DispatcherJob.perform_later(
|
|
202
|
+
provider: 'stripe',
|
|
203
|
+
event: params[:type],
|
|
204
|
+
payload: params.to_unsafe_h,
|
|
205
|
+
skip_idempotency_check: true
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Rails Controller Concern
|
|
210
|
+
|
|
211
|
+
Use the built-in controller concern for standardized webhook handling:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
class WebhooksController < ApplicationController
|
|
215
|
+
include Hooksmith::Rails::WebhooksController
|
|
216
|
+
|
|
217
|
+
def stripe
|
|
218
|
+
handle_webhook(
|
|
219
|
+
provider: 'stripe',
|
|
220
|
+
event: params[:type],
|
|
221
|
+
payload: params.to_unsafe_h
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def github
|
|
226
|
+
handle_webhook_async(
|
|
227
|
+
provider: 'github',
|
|
228
|
+
event: request.headers['X-GitHub-Event'],
|
|
229
|
+
payload: params.to_unsafe_h
|
|
230
|
+
)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
The concern provides:
|
|
236
|
+
- Automatic CSRF protection skip
|
|
237
|
+
- Optional signature verification (if configured)
|
|
238
|
+
- Consistent response codes (200, 401, 500)
|
|
239
|
+
- `handle_webhook` for synchronous processing
|
|
240
|
+
- `handle_webhook_async` for background processing
|
|
241
|
+
|
|
242
|
+
## Instrumentation
|
|
243
|
+
|
|
244
|
+
Hooksmith emits ActiveSupport::Notifications events for observability:
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
ActiveSupport::Notifications.subscribe(/hooksmith/) do |name, start, finish, id, payload|
|
|
248
|
+
Rails.logger.info "#{name}: #{payload.inspect} (#{finish - start}s)"
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
### Available Events
|
|
253
|
+
|
|
254
|
+
| Event | Description |
|
|
255
|
+
|-------|-------------|
|
|
256
|
+
| `dispatch.hooksmith` | Wraps the entire dispatch flow |
|
|
257
|
+
| `process.hooksmith` | Wraps processor execution |
|
|
258
|
+
| `no_processor.hooksmith` | When no processor matches |
|
|
259
|
+
| `multiple_processors.hooksmith` | When multiple processors match |
|
|
260
|
+
| `error.hooksmith` | When an error occurs |
|
|
261
|
+
|
|
262
|
+
### Subscribing to Specific Events
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
Hooksmith::Instrumentation.subscribe('process') do |name, start, finish, id, payload|
|
|
266
|
+
StatsD.timing("webhooks.#{payload[:provider]}.#{payload[:event]}", finish - start)
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
## Persisting Webhook Events
|
|
271
|
+
|
|
272
|
+
Hooksmith can optionally persist incoming webhook events to your database:
|
|
273
|
+
|
|
274
|
+
### 1. Create a Model
|
|
56
275
|
|
|
57
276
|
```ruby
|
|
58
277
|
class WebhookEvent < ApplicationRecord
|
|
@@ -60,106 +279,118 @@ class WebhookEvent < ApplicationRecord
|
|
|
60
279
|
end
|
|
61
280
|
```
|
|
62
281
|
|
|
63
|
-
2
|
|
282
|
+
### 2. Create a Migration
|
|
64
283
|
|
|
65
284
|
```ruby
|
|
66
285
|
create_table :webhook_events do |t|
|
|
67
286
|
t.string :provider
|
|
68
287
|
t.string :event
|
|
69
288
|
t.jsonb :payload
|
|
289
|
+
t.string :idempotency_key
|
|
70
290
|
t.datetime :received_at
|
|
71
291
|
t.timestamps
|
|
72
|
-
|
|
73
|
-
t.index
|
|
292
|
+
|
|
293
|
+
t.index [:provider, :idempotency_key], unique: true
|
|
294
|
+
t.index :event
|
|
295
|
+
t.index :received_at
|
|
74
296
|
end
|
|
75
297
|
```
|
|
76
298
|
|
|
77
|
-
3
|
|
299
|
+
### 3. Configure the Event Store
|
|
78
300
|
|
|
79
301
|
```ruby
|
|
80
302
|
Hooksmith.configure do |config|
|
|
81
303
|
config.event_store do |store|
|
|
82
304
|
store.enabled = true
|
|
83
|
-
store.model_class_name = 'WebhookEvent'
|
|
84
|
-
store.record_timing = :before
|
|
305
|
+
store.model_class_name = 'WebhookEvent'
|
|
306
|
+
store.record_timing = :before
|
|
85
307
|
store.mapper = ->(provider:, event:, payload:) {
|
|
86
308
|
{
|
|
87
309
|
provider: provider.to_s,
|
|
88
310
|
event: event.to_s,
|
|
89
|
-
payload
|
|
90
|
-
received_at:
|
|
311
|
+
payload: payload,
|
|
312
|
+
received_at: Time.current
|
|
91
313
|
}
|
|
92
314
|
}
|
|
93
315
|
end
|
|
94
316
|
end
|
|
95
317
|
```
|
|
96
318
|
|
|
97
|
-
##
|
|
98
|
-
Create a processor by inheriting from `Hooksmith::Processor::Base`:
|
|
319
|
+
## Error Handling
|
|
99
320
|
|
|
100
|
-
|
|
101
|
-
module Stripe
|
|
102
|
-
module Processor
|
|
103
|
-
module ChargeSucceeded
|
|
104
|
-
class Tenant < Hooksmith::Processor::Base
|
|
105
|
-
# Only handle events with a tenant_payment_id.
|
|
106
|
-
def can_handle?(payload)
|
|
107
|
-
payload.dig("metadata", "id").present?
|
|
108
|
-
end
|
|
321
|
+
Hooksmith provides specific error classes for different failure modes:
|
|
109
322
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
323
|
+
| Error Class | Description |
|
|
324
|
+
|-------------|-------------|
|
|
325
|
+
| `Hooksmith::Error` | Base error class |
|
|
326
|
+
| `Hooksmith::VerificationError` | Request signature verification failed |
|
|
327
|
+
| `Hooksmith::MultipleProcessorsError` | Multiple processors matched the payload |
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
begin
|
|
331
|
+
Hooksmith::Dispatcher.new(provider:, event:, payload:).run!
|
|
332
|
+
rescue Hooksmith::VerificationError => e
|
|
333
|
+
render json: { error: 'Invalid signature' }, status: :unauthorized
|
|
334
|
+
rescue Hooksmith::MultipleProcessorsError => e
|
|
335
|
+
render json: { error: 'Ambiguous processor' }, status: :unprocessable_entity
|
|
336
|
+
rescue StandardError => e
|
|
337
|
+
render json: { error: 'Processing failed' }, status: :internal_server_error
|
|
118
338
|
end
|
|
119
339
|
```
|
|
120
340
|
|
|
121
|
-
##
|
|
341
|
+
## Testing
|
|
122
342
|
|
|
123
|
-
|
|
343
|
+
The gem includes a full test suite using Minitest. Run the tests with:
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
bundle exec rake test
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Testing Your Processors
|
|
124
350
|
|
|
125
351
|
```ruby
|
|
126
|
-
class
|
|
127
|
-
|
|
352
|
+
class ChargeSucceededProcessorTest < ActiveSupport::TestCase
|
|
353
|
+
test 'processes successful charges' do
|
|
354
|
+
payload = { 'data' => { 'object' => { 'id' => 'ch_123', 'status' => 'succeeded' } } }
|
|
355
|
+
processor = Stripe::ChargeSucceededProcessor.new(payload)
|
|
128
356
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
event = params[:event] || "charge_succeeded"
|
|
132
|
-
payload = params[:data] # Adjust extraction as needed
|
|
357
|
+
assert processor.can_handle?(payload)
|
|
358
|
+
processor.process!
|
|
133
359
|
|
|
134
|
-
|
|
135
|
-
head :ok
|
|
136
|
-
rescue Hooksmith::MultipleProcessorsError => e
|
|
137
|
-
render json: { error: e.message }, status: :unprocessable_entity
|
|
138
|
-
rescue StandardError => e
|
|
139
|
-
render json: { error: e.message }, status: :internal_server_error
|
|
360
|
+
assert Payment.find_by(stripe_charge_id: 'ch_123').paid?
|
|
140
361
|
end
|
|
141
362
|
end
|
|
142
363
|
```
|
|
143
364
|
|
|
144
|
-
##
|
|
145
|
-
The gem includes a full test suite using Minitest with 100% branch coverage. See the test/ directory for examples. You can run the tests with:
|
|
365
|
+
## Configuration Reference
|
|
146
366
|
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
|
|
367
|
+
```ruby
|
|
368
|
+
Hooksmith.configure do |config|
|
|
369
|
+
config.provider(:stripe) do |stripe|
|
|
370
|
+
stripe.register(:charge_succeeded, 'Stripe::ChargeSucceededProcessor')
|
|
150
371
|
|
|
151
|
-
|
|
372
|
+
stripe.verifier = Hooksmith::Verifiers::Hmac.new(
|
|
373
|
+
secret: ENV['STRIPE_WEBHOOK_SECRET'],
|
|
374
|
+
header: 'Stripe-Signature',
|
|
375
|
+
algorithm: :sha256
|
|
376
|
+
)
|
|
152
377
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
SimpleCov.start
|
|
156
|
-
```
|
|
378
|
+
stripe.idempotency_key = ->(payload) { payload['id'] }
|
|
379
|
+
end
|
|
157
380
|
|
|
158
|
-
|
|
381
|
+
config.event_store do |store|
|
|
382
|
+
store.enabled = true
|
|
383
|
+
store.model_class_name = 'WebhookEvent'
|
|
384
|
+
store.record_timing = :before
|
|
385
|
+
store.mapper = ->(provider:, event:, payload:) { ... }
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
```
|
|
159
389
|
|
|
160
390
|
## Contributing
|
|
161
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/gregorypreve/hooksmith.
|
|
162
391
|
|
|
392
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/RivageImmo/Hooksmith.
|
|
163
393
|
|
|
164
394
|
## License
|
|
395
|
+
|
|
165
396
|
The gem is available as open source under the terms of the MIT License.
|
|
@@ -2,16 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
module Hooksmith
|
|
4
4
|
module Config
|
|
5
|
-
# Provider is used internally by the DSL to collect processor registrations
|
|
5
|
+
# Provider is used internally by the DSL to collect processor registrations,
|
|
6
|
+
# optional verifier configuration, and idempotency settings.
|
|
7
|
+
#
|
|
8
|
+
# Uses string keys internally to prevent Symbol DoS attacks when processing
|
|
9
|
+
# untrusted webhook input.
|
|
6
10
|
class Provider
|
|
7
|
-
# @return [
|
|
11
|
+
# @return [String] the provider name.
|
|
8
12
|
attr_reader :provider
|
|
9
13
|
# @return [Array<Hash>] list of entries registered.
|
|
10
14
|
attr_reader :entries
|
|
15
|
+
# @return [Hooksmith::Verifiers::Base, nil] the verifier for this provider.
|
|
16
|
+
# @example
|
|
17
|
+
# config.provider(:stripe) do |stripe|
|
|
18
|
+
# stripe.verifier = Hooksmith::Verifiers::Hmac.new(
|
|
19
|
+
# secret: ENV['STRIPE_WEBHOOK_SECRET'],
|
|
20
|
+
# header: 'Stripe-Signature'
|
|
21
|
+
# )
|
|
22
|
+
# end
|
|
23
|
+
attr_accessor :verifier
|
|
24
|
+
# @return [Proc, nil] the idempotency key extractor for this provider.
|
|
25
|
+
# @example
|
|
26
|
+
# config.provider(:stripe) do |stripe|
|
|
27
|
+
# stripe.idempotency_key = ->(payload) { payload['id'] }
|
|
28
|
+
# end
|
|
29
|
+
attr_accessor :idempotency_key
|
|
11
30
|
|
|
12
31
|
def initialize(provider)
|
|
13
|
-
@provider = provider
|
|
32
|
+
@provider = provider.to_s
|
|
14
33
|
@entries = []
|
|
34
|
+
@verifier = nil
|
|
35
|
+
@idempotency_key = nil
|
|
15
36
|
end
|
|
16
37
|
|
|
17
38
|
# Registers a processor for a specific event.
|
|
@@ -19,7 +40,7 @@ module Hooksmith
|
|
|
19
40
|
# @param event [Symbol, String] the event name.
|
|
20
41
|
# @param processor_class_name [String] the processor class name.
|
|
21
42
|
def register(event, processor_class_name)
|
|
22
|
-
entries << { event: event.
|
|
43
|
+
entries << { event: event.to_s, processor: processor_class_name }
|
|
23
44
|
end
|
|
24
45
|
end
|
|
25
46
|
end
|
|
@@ -3,16 +3,27 @@
|
|
|
3
3
|
# Provides a DSL for registering webhook processors by provider and event.
|
|
4
4
|
#
|
|
5
5
|
module Hooksmith
|
|
6
|
-
# Configuration holds the registry of all processors.
|
|
6
|
+
# Configuration holds the registry of all processors, verifiers, and idempotency settings.
|
|
7
|
+
#
|
|
8
|
+
# The registry uses string keys internally to prevent Symbol DoS attacks
|
|
9
|
+
# when processing untrusted webhook input. Provider and event names from
|
|
10
|
+
# external sources are converted to strings, not symbols.
|
|
7
11
|
class Configuration
|
|
8
|
-
# @return [Hash] a registry mapping provider
|
|
12
|
+
# @return [Hash] a registry mapping provider strings to arrays of processor entries.
|
|
9
13
|
attr_reader :registry
|
|
14
|
+
# @return [Hash] a registry mapping provider strings to verifier instances.
|
|
15
|
+
attr_reader :verifiers
|
|
16
|
+
# @return [Hash] a registry mapping provider strings to idempotency key extractors.
|
|
17
|
+
attr_reader :idempotency_keys
|
|
10
18
|
# @return [Hooksmith::Config::EventStore] configuration for event persistence
|
|
11
19
|
attr_reader :event_store_config
|
|
12
20
|
|
|
13
21
|
def initialize
|
|
14
|
-
# Registry structure: {
|
|
22
|
+
# Registry structure: { "provider" => [{ event: "event", processor: "ProcessorClass" }, ...] }
|
|
23
|
+
# Uses strings to prevent Symbol DoS from untrusted webhook input
|
|
15
24
|
@registry = Hash.new { |hash, key| hash[key] = [] }
|
|
25
|
+
@verifiers = {}
|
|
26
|
+
@idempotency_keys = {}
|
|
16
27
|
@event_store_config = Hooksmith::Config::EventStore.new
|
|
17
28
|
end
|
|
18
29
|
|
|
@@ -23,7 +34,10 @@ module Hooksmith
|
|
|
23
34
|
def provider(provider_name)
|
|
24
35
|
provider_config = Hooksmith::Config::Provider.new(provider_name)
|
|
25
36
|
yield(provider_config)
|
|
26
|
-
|
|
37
|
+
provider_key = provider_name.to_s
|
|
38
|
+
registry[provider_key].concat(provider_config.entries)
|
|
39
|
+
@verifiers[provider_key] = provider_config.verifier if provider_config.verifier
|
|
40
|
+
@idempotency_keys[provider_key] = provider_config.idempotency_key if provider_config.idempotency_key
|
|
27
41
|
end
|
|
28
42
|
|
|
29
43
|
# Direct registration of a processor.
|
|
@@ -32,7 +46,7 @@ module Hooksmith
|
|
|
32
46
|
# @param event [Symbol, String] the event name
|
|
33
47
|
# @param processor_class_name [String] the processor class name
|
|
34
48
|
def register_processor(provider, event, processor_class_name)
|
|
35
|
-
registry[provider.
|
|
49
|
+
registry[provider.to_s] << { event: event.to_s, processor: processor_class_name }
|
|
36
50
|
end
|
|
37
51
|
|
|
38
52
|
# Returns all processor entries for a given provider and event.
|
|
@@ -41,7 +55,31 @@ module Hooksmith
|
|
|
41
55
|
# @param event [Symbol, String] the event name
|
|
42
56
|
# @return [Array<Hash>] the array of matching entries.
|
|
43
57
|
def processors_for(provider, event)
|
|
44
|
-
registry[provider.
|
|
58
|
+
registry[provider.to_s].select { |entry| entry[:event] == event.to_s }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the verifier for a given provider.
|
|
62
|
+
#
|
|
63
|
+
# @param provider [Symbol, String] the provider name
|
|
64
|
+
# @return [Hooksmith::Verifiers::Base, nil] the verifier or nil if not configured
|
|
65
|
+
def verifier_for(provider)
|
|
66
|
+
@verifiers[provider.to_s]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Registers a verifier for a provider directly.
|
|
70
|
+
#
|
|
71
|
+
# @param provider [Symbol, String] the provider name
|
|
72
|
+
# @param verifier [Hooksmith::Verifiers::Base] the verifier instance
|
|
73
|
+
def register_verifier(provider, verifier)
|
|
74
|
+
@verifiers[provider.to_s] = verifier
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns the idempotency key extractor for a given provider.
|
|
78
|
+
#
|
|
79
|
+
# @param provider [Symbol, String] the provider name
|
|
80
|
+
# @return [Proc, nil] the idempotency key extractor or nil if not configured
|
|
81
|
+
def idempotency_key_for(provider)
|
|
82
|
+
@idempotency_keys[provider.to_s]
|
|
45
83
|
end
|
|
46
84
|
|
|
47
85
|
# Configure event store persistence.
|
|
@@ -54,7 +92,8 @@ module Hooksmith
|
|
|
54
92
|
# store.model_class_name = 'MyApp::WebhookEvent'
|
|
55
93
|
# store.record_timing = :before # or :after, or :both
|
|
56
94
|
# store.mapper = ->(provider:, event:, payload:) {
|
|
57
|
-
#
|
|
95
|
+
# now = Time.respond_to?(:current) ? Time.current : Time.now
|
|
96
|
+
# { provider:, event: event.to_s, payload:, received_at: now }
|
|
58
97
|
# }
|
|
59
98
|
# end
|
|
60
99
|
# end
|