hooksmith 0.1.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c80476ba099d00e9dfefb14d1b05d696a4b5cef3eb655e6f145c334e86fcf1ac
4
- data.tar.gz: 4c1e6ae0d6cdfe4e1a013e2028b446912a636b295d949d499801e83ce4a640c8
3
+ metadata.gz: 1c87efd7d2887fc38d26d7615fbb913efaa086f09b0753b84479d41f87c4a4fd
4
+ data.tar.gz: 395f26ad9af550a7d64596edd3f23a486db879f8fc71aa9e2997a4e468eb785e
5
5
  SHA512:
6
- metadata.gz: 0c004376be79241af2a779aa9eeafe4bd4075d09ab45da029d8c9d88ed6faa2d1248652b160ac0761a107c7e0ddddec81a41cec8ed1f5e147f1e6f43d8dd9ca0
7
- data.tar.gz: c555b8226888a88774fb0fd97bd3b57a1741a35bdd615d462bbd479786ab6a1aede0875a070cb9fcbc0d76f4b3ad6ac85256ff023dd2d9e9ccd552fcd8d928fe
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:** 100% branch coverage for robust behavior.
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', '~> 0.1.1'
23
+ gem 'hooksmith', '~> 1.0'
19
24
  ```
20
25
 
21
26
  Then execute:
@@ -29,91 +34,363 @@ Or install it yourself as:
29
34
  gem install hooksmith
30
35
  ```
31
36
 
32
- ## Usage
37
+ ## Quick Start
33
38
 
34
- ### Configuration
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::Processor::ChargeSucceeded::First')
42
- stripe.register(:charge_succeeded, 'Stripe::Processor::ChargeSucceeded::Second')
46
+ stripe.register(:charge_succeeded, 'Stripe::ChargeSucceededProcessor')
47
+ stripe.register(:payment_failed, 'Stripe::PaymentFailedProcessor')
43
48
  end
44
49
 
45
- config.provider(:paypal) do |paypal|
46
- paypal.register(:payment_received, 'Paypal::Processor::PaymentReceived')
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
- ## Implementing a Processor
57
+ ### 2. Create a Processor
58
+
52
59
  Create a processor by inheriting from `Hooksmith::Processor::Base`:
53
60
 
54
61
  ```ruby
55
- module Stripe
56
- module Processor
57
- module ChargeSucceeded
58
- class Tenant < Hooksmith::Processor::Base
59
- # Only handle events with a tenant_payment_id.
60
- def can_handle?(payload)
61
- payload.dig("metadata", "id").present?
62
- end
62
+ class Stripe::ChargeSucceededProcessor < Hooksmith::Processor::Base
63
+ def can_handle?(payload)
64
+ payload.dig('data', 'object', 'status') == 'succeeded'
65
+ end
63
66
 
64
- def process!
65
- tenant_payment_id = payload.dig("metadata", "id")
66
- # Add your business logic here (e.g., update database records).
67
- puts "Processed id : #{id}"
68
- end
69
- end
70
- end
67
+ def process!
68
+ charge_id = payload.dig('data', 'object', 'id')
69
+ Payment.find_by(stripe_charge_id: charge_id)&.mark_as_paid!
71
70
  end
72
71
  end
73
72
  ```
74
73
 
75
- ## Dispatching a Webhook
76
-
77
- Use the dispatcher in your webhook controller:
74
+ ### 3. Handle Webhooks in Your Controller
78
75
 
79
76
  ```ruby
80
77
  class WebhooksController < ApplicationController
81
78
  skip_before_action :verify_authenticity_token
82
79
 
83
- def receive
84
- provider = params[:provider] || "stripe"
85
- event = params[:event] || "charge_succeeded"
86
- payload = params[:data] # Adjust extraction as needed
80
+ def stripe
81
+ Hooksmith::Dispatcher.new(
82
+ provider: :stripe,
83
+ event: params[:type],
84
+ payload: params.to_unsafe_h
85
+ ).run!
87
86
 
88
- Hooksmith::Dispatcher.new(provider: provider, event: event, payload: payload).run!
89
87
  head :ok
90
- rescue Hooksmith::MultipleProcessorsError => e
91
- render json: { error: e.message }, status: :unprocessable_entity
92
88
  rescue StandardError => e
93
- render json: { error: e.message }, status: :internal_server_error
89
+ head :internal_server_error
94
90
  end
95
91
  end
96
92
  ```
97
93
 
98
- ## Testing
99
- 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:
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
139
+
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
100
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
101
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
275
+
276
+ ```ruby
277
+ class WebhookEvent < ApplicationRecord
278
+ self.table_name = 'webhook_events'
279
+ end
280
+ ```
281
+
282
+ ### 2. Create a Migration
283
+
284
+ ```ruby
285
+ create_table :webhook_events do |t|
286
+ t.string :provider
287
+ t.string :event
288
+ t.jsonb :payload
289
+ t.string :idempotency_key
290
+ t.datetime :received_at
291
+ t.timestamps
292
+
293
+ t.index [:provider, :idempotency_key], unique: true
294
+ t.index :event
295
+ t.index :received_at
296
+ end
297
+ ```
298
+
299
+ ### 3. Configure the Event Store
300
+
301
+ ```ruby
302
+ Hooksmith.configure do |config|
303
+ config.event_store do |store|
304
+ store.enabled = true
305
+ store.model_class_name = 'WebhookEvent'
306
+ store.record_timing = :before
307
+ store.mapper = ->(provider:, event:, payload:) {
308
+ {
309
+ provider: provider.to_s,
310
+ event: event.to_s,
311
+ payload: payload,
312
+ received_at: Time.current
313
+ }
314
+ }
315
+ end
316
+ end
317
+ ```
318
+
319
+ ## Error Handling
320
+
321
+ Hooksmith provides specific error classes for different failure modes:
322
+
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
338
+ end
339
+ ```
340
+
341
+ ## Testing
342
+
343
+ The gem includes a full test suite using Minitest. Run the tests with:
344
+
345
+ ```bash
102
346
  bundle exec rake test
103
347
  ```
104
348
 
105
- If you want to check test coverage, you can integrate SimpleCov by adding the following at the top of your test/test_helper.rb:
349
+ ### Testing Your Processors
106
350
 
107
351
  ```ruby
108
- require "simplecov"
109
- SimpleCov.start
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)
356
+
357
+ assert processor.can_handle?(payload)
358
+ processor.process!
359
+
360
+ assert Payment.find_by(stripe_charge_id: 'ch_123').paid?
361
+ end
362
+ end
110
363
  ```
111
364
 
112
- Then run the tests to generate a coverage report.
365
+ ## Configuration Reference
366
+
367
+ ```ruby
368
+ Hooksmith.configure do |config|
369
+ config.provider(:stripe) do |stripe|
370
+ stripe.register(:charge_succeeded, 'Stripe::ChargeSucceededProcessor')
371
+
372
+ stripe.verifier = Hooksmith::Verifiers::Hmac.new(
373
+ secret: ENV['STRIPE_WEBHOOK_SECRET'],
374
+ header: 'Stripe-Signature',
375
+ algorithm: :sha256
376
+ )
377
+
378
+ stripe.idempotency_key = ->(payload) { payload['id'] }
379
+ end
380
+
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
+ ```
113
389
 
114
390
  ## Contributing
115
- Bug reports and pull requests are welcome on GitHub at https://github.com/gregorypreve/hooksmith.
116
391
 
392
+ Bug reports and pull requests are welcome on GitHub at https://github.com/RivageImmo/Hooksmith.
117
393
 
118
394
  ## License
395
+
119
396
  The gem is available as open source under the terms of the MIT License.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ module Config
5
+ # EventStore holds settings for optional event persistence.
6
+ class EventStore
7
+ # Whether persistence is enabled.
8
+ attr_accessor :enabled
9
+ # Class name of the model used to persist events. Must respond to .create!(attrs)
10
+ attr_accessor :model_class_name
11
+ # Proc to map provider/event/payload to attributes persisted
12
+ attr_accessor :mapper
13
+ # When to record: :before, :after, or :both
14
+ attr_accessor :record_timing
15
+
16
+ def initialize
17
+ @enabled = false
18
+ # No default model in the gem; applications should provide their own model
19
+ @model_class_name = nil
20
+ @record_timing = :before
21
+ @mapper = default_mapper
22
+ end
23
+
24
+ def model_class
25
+ return nil if model_class_name.nil?
26
+
27
+ Object.const_get(model_class_name)
28
+ rescue NameError
29
+ nil
30
+ end
31
+
32
+ private
33
+
34
+ def default_mapper
35
+ lambda do |provider:, event:, payload:|
36
+ now = Time.respond_to?(:current) ? Time.current : Time.now
37
+ {
38
+ provider: provider.to_s,
39
+ event: event.to_s,
40
+ payload:,
41
+ received_at: now
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ module Config
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.
10
+ class Provider
11
+ # @return [String] the provider name.
12
+ attr_reader :provider
13
+ # @return [Array<Hash>] list of entries registered.
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
30
+
31
+ def initialize(provider)
32
+ @provider = provider.to_s
33
+ @entries = []
34
+ @verifier = nil
35
+ @idempotency_key = nil
36
+ end
37
+
38
+ # Registers a processor for a specific event.
39
+ #
40
+ # @param event [Symbol, String] the event name.
41
+ # @param processor_class_name [String] the processor class name.
42
+ def register(event, processor_class_name)
43
+ entries << { event: event.to_s, processor: processor_class_name }
44
+ end
45
+ end
46
+ end
47
+ end