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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82cacda1d785736b14476d699c2d18e90d65036f2b4e291d03dd2b393a907d14
4
- data.tar.gz: 65407cf717e2e3ac6edede0392ed319250b3e0c06fc61dc0d730fd82fafead3c
3
+ metadata.gz: 1c87efd7d2887fc38d26d7615fbb913efaa086f09b0753b84479d41f87c4a4fd
4
+ data.tar.gz: 395f26ad9af550a7d64596edd3f23a486db879f8fc71aa9e2997a4e468eb785e
5
5
  SHA512:
6
- metadata.gz: bbd5f49b4248a597997816a03c0e9624c93ec3fe8185837398564360a16663defe758463ba2528f1af38e53e0816fadf1b61d5243591835f8b84cd9c822a7f40
7
- data.tar.gz: b13ef16ba8d73c011c410ca838575f9a4eb8b3bdc58560b392cf88350f8520d0752a7081e556dc649fa5e6ad89441e975bde9e57085717b98356516a6e801f81
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,30 +34,244 @@ 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
- ### Persisting Webhook Events (Optional)
57
+ ### 2. Create a Processor
52
58
 
53
- Hooksmith can optionally persist incoming webhook events to your database. Configure it with your own ActiveRecord model and mapping logic.
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
- 1) Create a model in your app (example):
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) Example migration (customize fields as needed):
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
- t.index :event
73
- t.index :received_at
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) Configure Hooksmith to record events:
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' # your model class
84
- store.record_timing = :before # :before, :after, or :both
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: (Time.respond_to?(:current) ? Time.current : Time.now)
311
+ payload: payload,
312
+ received_at: Time.current
91
313
  }
92
314
  }
93
315
  end
94
316
  end
95
317
  ```
96
318
 
97
- ## Implementing a Processor
98
- Create a processor by inheriting from `Hooksmith::Processor::Base`:
319
+ ## Error Handling
99
320
 
100
- ```ruby
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
- def process!
111
- tenant_payment_id = payload.dig("metadata", "id")
112
- # Add your business logic here (e.g., update database records).
113
- puts "Processed id : #{id}"
114
- end
115
- end
116
- end
117
- end
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
- ## Dispatching a Webhook
341
+ ## Testing
122
342
 
123
- Use the dispatcher in your webhook controller:
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 WebhooksController < ApplicationController
127
- skip_before_action :verify_authenticity_token
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
- def receive
130
- provider = params[:provider] || "stripe"
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
- Hooksmith::Dispatcher.new(provider: provider, event: event, payload: payload).run!
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
- ## Testing
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
- bundle exec rake test
149
- ```
367
+ ```ruby
368
+ Hooksmith.configure do |config|
369
+ config.provider(:stripe) do |stripe|
370
+ stripe.register(:charge_succeeded, 'Stripe::ChargeSucceededProcessor')
150
371
 
151
- If you want to check test coverage, you can integrate SimpleCov by adding the following at the top of your test/test_helper.rb:
372
+ stripe.verifier = Hooksmith::Verifiers::Hmac.new(
373
+ secret: ENV['STRIPE_WEBHOOK_SECRET'],
374
+ header: 'Stripe-Signature',
375
+ algorithm: :sha256
376
+ )
152
377
 
153
- ```ruby
154
- require "simplecov"
155
- SimpleCov.start
156
- ```
378
+ stripe.idempotency_key = ->(payload) { payload['id'] }
379
+ end
157
380
 
158
- Then run the tests to generate a coverage report.
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 [Symbol, String] the provider name.
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.to_sym, processor: processor_class_name }
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 symbols to arrays of processor entries.
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: { provider_symbol => [{ event: event_symbol, processor: ProcessorClass }, ...] }
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
- registry[provider_name.to_sym].concat(provider_config.entries)
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.to_sym] << { event: event.to_sym, processor: processor_class_name }
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.to_sym].select { |entry| entry[:event] == event.to_sym }
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
- # { provider:, event: event.to_s, payload:, received_at: (Time.respond_to?(:current) ? Time.current : Time.now) }
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