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.
@@ -3,18 +3,28 @@
3
3
  module Hooksmith
4
4
  # Dispatcher routes incoming webhook payloads to the appropriate processor.
5
5
  #
6
+ # Uses string keys internally to prevent Symbol DoS attacks when processing
7
+ # untrusted webhook input from external sources.
8
+ #
6
9
  # @example Dispatch a webhook event:
7
10
  # Hooksmith::Dispatcher.new(provider: :stripe, event: :charge_succeeded, payload: payload).run!
8
11
  #
9
12
  class Dispatcher
13
+ # @return [String] the provider name
14
+ attr_reader :provider
15
+ # @return [String] the event name
16
+ attr_reader :event
17
+ # @return [Hash] the webhook payload
18
+ attr_reader :payload
19
+
10
20
  # Initializes a new Dispatcher.
11
21
  #
12
22
  # @param provider [Symbol, String] the provider (e.g., :stripe)
13
23
  # @param event [Symbol, String] the event (e.g., :charge_succeeded)
14
24
  # @param payload [Hash] the webhook payload data.
15
25
  def initialize(provider:, event:, payload:)
16
- @provider = provider.to_sym
17
- @event = event.to_sym
26
+ @provider = provider.to_s
27
+ @event = event.to_s
18
28
  @payload = payload
19
29
  end
20
30
 
@@ -22,54 +32,81 @@ module Hooksmith
22
32
  #
23
33
  # Instantiates each processor registered for the given provider and event,
24
34
  # then selects the ones that can handle the payload using the can_handle? method.
25
- # - If no processors qualify, logs a warning.
35
+ # - If no processors qualify, logs a warning (or raises NoProcessorError in strict mode).
26
36
  # - If more than one qualifies, raises MultipleProcessorsError.
27
37
  # - Otherwise, processes the event with the single matching processor.
28
38
  #
29
- # @raise [MultipleProcessorsError] if multiple processors qualify.
39
+ # @raise [Hooksmith::MultipleProcessorsError] if multiple processors qualify.
40
+ # @raise [Hooksmith::NoProcessorError] if no processors qualify (strict mode only).
41
+ # @raise [Hooksmith::ProcessorError] if the processor raises an error.
42
+ # @return [Object, nil] the result of the processor, or nil if no processor matched.
30
43
  def run!
31
- # Optionally record the incoming event before processing.
32
- Hooksmith::EventRecorder.record!(provider: @provider, event: @event, payload: @payload, timing: :before)
44
+ Hooksmith::Instrumentation.instrument('dispatch', provider: @provider, event: @event) do
45
+ dispatch_webhook
46
+ end
47
+ end
33
48
 
34
- # Fetch all processors registered for this provider and event.
35
- entries = Hooksmith.configuration.processors_for(@provider, @event)
49
+ private
50
+
51
+ def dispatch_webhook
52
+ record_event(:before)
53
+ matching = find_matching_processors
54
+
55
+ return handle_no_processor if matching.empty?
56
+
57
+ handle_multiple_processors(matching) if matching.size > 1
58
+
59
+ execute_processor(matching.first)
60
+ rescue Hooksmith::Error
61
+ raise
62
+ rescue StandardError => e
63
+ publish_error(e)
64
+ Hooksmith.logger.error("Error processing #{@provider} event #{@event}: #{e.message}")
65
+ raise e
66
+ end
36
67
 
37
- # Instantiate each processor and filter by condition.
38
- matching_processors = entries.map do |entry|
68
+ def find_matching_processors
69
+ entries = Hooksmith.configuration.processors_for(@provider, @event)
70
+ entries.filter_map do |entry|
39
71
  processor = Object.const_get(entry[:processor]).new(@payload)
40
72
  processor if processor.can_handle?(@payload)
41
- end.compact
42
-
43
- if matching_processors.empty?
44
- Hooksmith.logger.warn("No processor registered for #{@provider} event #{@event} could handle the payload")
45
- return
46
73
  end
74
+ end
47
75
 
48
- # If more than one processor qualifies, raise an error.
49
- raise MultipleProcessorsError.new(@provider, @event, @payload) if matching_processors.size > 1
76
+ def handle_no_processor
77
+ Hooksmith::Instrumentation.publish('no_processor', provider: @provider, event: @event)
78
+ Hooksmith.logger.warn("No processor registered for #{@provider} event #{@event} could handle the payload")
79
+ nil
80
+ end
50
81
 
51
- # Exactly one matching processor.
52
- result = matching_processors.first.process!
82
+ def handle_multiple_processors(matching)
83
+ processor_names = matching.map { |p| p.class.name }
84
+ Hooksmith::Instrumentation.publish(
85
+ 'multiple_processors',
86
+ provider: @provider, event: @event, processor_count: matching.size
87
+ )
88
+ raise MultipleProcessorsError.new(@provider, @event, @payload, processor_names:)
89
+ end
53
90
 
54
- # Optionally record the event after successful processing.
55
- Hooksmith::EventRecorder.record!(provider: @provider, event: @event, payload: @payload, timing: :after)
91
+ def execute_processor(processor)
92
+ result = Hooksmith::Instrumentation.instrument(
93
+ 'process',
94
+ provider: @provider, event: @event, processor: processor.class.name
95
+ ) { processor.process! }
56
96
 
97
+ record_event(:after)
57
98
  result
58
- rescue StandardError => e
59
- Hooksmith.logger.error("Error processing #{@provider} event #{@event}: #{e.message}")
60
- raise e
61
99
  end
62
- end
63
100
 
64
- # Raised when multiple processors can handle the same event.
65
- class MultipleProcessorsError < StandardError
66
- # Initializes the error with details about the provider, event, and payload.
67
- #
68
- # @param provider [Symbol] the provider name.
69
- # @param event [Symbol] the event name.
70
- # @param payload [Hash] the webhook payload.
71
- def initialize(provider, event, payload)
72
- super("Multiple processors found for #{provider} event #{event}. Payload: #{payload}")
101
+ def record_event(timing)
102
+ Hooksmith::EventRecorder.record!(provider: @provider, event: @event, payload: @payload, timing:)
103
+ end
104
+
105
+ def publish_error(error)
106
+ Hooksmith::Instrumentation.publish(
107
+ 'error',
108
+ provider: @provider, event: @event, error: error.message, error_class: error.class.name
109
+ )
73
110
  end
74
111
  end
75
112
  end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ # Base error class for all Hooksmith errors.
5
+ #
6
+ # All Hooksmith errors inherit from this class, allowing you to rescue
7
+ # all Hooksmith-related errors with a single rescue clause.
8
+ #
9
+ # @example Rescuing all Hooksmith errors
10
+ # begin
11
+ # Hooksmith::Dispatcher.new(...).run!
12
+ # rescue Hooksmith::Error => e
13
+ # logger.error("Hooksmith error: #{e.message}")
14
+ # end
15
+ #
16
+ class Error < StandardError
17
+ # @return [String, nil] the provider name
18
+ attr_reader :provider
19
+ # @return [String, nil] the event name
20
+ attr_reader :event
21
+
22
+ # Initializes a new Error.
23
+ #
24
+ # @param message [String] the error message
25
+ # @param provider [String, Symbol, nil] the provider name
26
+ # @param event [String, Symbol, nil] the event name
27
+ def initialize(message = nil, provider: nil, event: nil)
28
+ @provider = provider&.to_s
29
+ @event = event&.to_s
30
+ super(message)
31
+ end
32
+ end
33
+
34
+ # Raised when webhook request verification fails.
35
+ #
36
+ # This error is raised by verifiers when the incoming request
37
+ # does not pass authentication checks.
38
+ #
39
+ # @example Raising a verification error
40
+ # raise Hooksmith::VerificationError, 'Invalid signature'
41
+ #
42
+ # @example Raising with additional context
43
+ # raise Hooksmith::VerificationError.new('Signature mismatch', provider: 'stripe', reason: 'invalid_hmac')
44
+ #
45
+ class VerificationError < Error
46
+ # @return [String, nil] the reason for verification failure
47
+ attr_reader :reason
48
+
49
+ # Initializes a new VerificationError.
50
+ #
51
+ # @param message [String] the error message
52
+ # @param provider [String, nil] the provider name
53
+ # @param event [String, nil] the event name
54
+ # @param reason [String, nil] additional reason for failure
55
+ def initialize(message = 'Webhook verification failed', provider: nil, event: nil, reason: nil)
56
+ @reason = reason
57
+ super(message, provider:, event:)
58
+ end
59
+ end
60
+
61
+ # Raised when no processor is registered for an event.
62
+ #
63
+ # This error can be raised when strict mode is enabled and no processor
64
+ # is found for a given provider/event combination.
65
+ #
66
+ # @example
67
+ # raise Hooksmith::NoProcessorError.new('stripe', 'unknown_event')
68
+ #
69
+ class NoProcessorError < Error
70
+ # Initializes a new NoProcessorError.
71
+ #
72
+ # @param provider [String, Symbol] the provider name
73
+ # @param event [String, Symbol] the event name
74
+ def initialize(provider, event)
75
+ super(
76
+ "No processor registered for #{provider} event #{event}",
77
+ provider:,
78
+ event:
79
+ )
80
+ end
81
+ end
82
+
83
+ # Raised when multiple processors can handle the same event.
84
+ #
85
+ # This error is raised when more than one processor's `can_handle?`
86
+ # method returns true for the same webhook event. Hooksmith enforces
87
+ # exactly-one processor semantics.
88
+ #
89
+ # This error intentionally does not include the full payload in the message
90
+ # to prevent PII exposure in logs and error tracking systems.
91
+ #
92
+ # @example
93
+ # raise Hooksmith::MultipleProcessorsError.new('stripe', 'charge.succeeded', payload)
94
+ #
95
+ class MultipleProcessorsError < Error
96
+ # @return [Integer] the number of bytes in the payload (for debugging)
97
+ attr_reader :payload_size
98
+ # @return [Array<String>] the names of the matching processor classes
99
+ attr_reader :processor_names
100
+
101
+ # Initializes the error with details about the provider and event.
102
+ #
103
+ # @param provider [String, Symbol] the provider name
104
+ # @param event [String, Symbol] the event name
105
+ # @param payload [Hash] the webhook payload (not included in message to prevent PII exposure)
106
+ # @param processor_names [Array<String>] the names of the matching processor classes
107
+ def initialize(provider, event, payload, processor_names: [])
108
+ @payload_size = payload.to_s.bytesize
109
+ @processor_names = processor_names
110
+ super(
111
+ "Multiple processors found for #{provider} event #{event} (payload_size=#{@payload_size} bytes)",
112
+ provider:,
113
+ event:
114
+ )
115
+ end
116
+ end
117
+
118
+ # Raised when a processor encounters an error during processing.
119
+ #
120
+ # This error wraps the original exception and provides additional context
121
+ # about which processor failed and for which event.
122
+ #
123
+ # @example
124
+ # raise Hooksmith::ProcessorError.new(
125
+ # 'Payment processing failed',
126
+ # provider: 'stripe',
127
+ # event: 'charge.succeeded',
128
+ # processor_class: 'StripeChargeProcessor',
129
+ # original_error: original_exception
130
+ # )
131
+ #
132
+ class ProcessorError < Error
133
+ # @return [String] the processor class name
134
+ attr_reader :processor_class
135
+ # @return [Exception, nil] the original exception
136
+ attr_reader :original_error
137
+
138
+ # Initializes a new ProcessorError.
139
+ #
140
+ # @param message [String] the error message
141
+ # @param provider [String, Symbol] the provider name
142
+ # @param event [String, Symbol] the event name
143
+ # @param processor_class [String, Class] the processor class name
144
+ # @param original_error [Exception, nil] the original exception
145
+ def initialize(message, provider:, event:, processor_class:, original_error: nil)
146
+ @processor_class = processor_class.to_s
147
+ @original_error = original_error
148
+ super(message, provider:, event:)
149
+ end
150
+ end
151
+
152
+ # Raised when the event name is not recognized or invalid.
153
+ #
154
+ # This error can be raised when strict event validation is enabled
155
+ # and an unknown event type is received.
156
+ #
157
+ # @example
158
+ # raise Hooksmith::UnknownEventError.new('stripe', 'invalid.event.type')
159
+ #
160
+ class UnknownEventError < Error
161
+ # Initializes a new UnknownEventError.
162
+ #
163
+ # @param provider [String, Symbol] the provider name
164
+ # @param event [String, Symbol] the event name
165
+ def initialize(provider, event)
166
+ super(
167
+ "Unknown event '#{event}' for provider '#{provider}'",
168
+ provider:,
169
+ event:
170
+ )
171
+ end
172
+ end
173
+
174
+ # Raised when the payload is invalid or malformed.
175
+ #
176
+ # This error can be raised during payload validation when the
177
+ # webhook data doesn't match expected schema or format.
178
+ #
179
+ # @example
180
+ # raise Hooksmith::InvalidPayloadError.new(
181
+ # 'Missing required field: id',
182
+ # provider: 'stripe',
183
+ # event: 'charge.succeeded'
184
+ # )
185
+ #
186
+ class InvalidPayloadError < Error
187
+ # @return [Array<String>] list of validation errors
188
+ attr_reader :validation_errors
189
+
190
+ # Initializes a new InvalidPayloadError.
191
+ #
192
+ # @param message [String] the error message
193
+ # @param provider [String, Symbol, nil] the provider name
194
+ # @param event [String, Symbol, nil] the event name
195
+ # @param validation_errors [Array<String>] list of validation errors
196
+ def initialize(message = 'Invalid webhook payload', provider: nil, event: nil, validation_errors: [])
197
+ @validation_errors = validation_errors
198
+ super(message, provider:, event:)
199
+ end
200
+ end
201
+
202
+ # Raised when event persistence fails.
203
+ #
204
+ # This error is raised when the event store is configured but
205
+ # fails to persist the webhook event.
206
+ #
207
+ # @example
208
+ # raise Hooksmith::PersistenceError.new(
209
+ # 'Failed to save webhook event',
210
+ # provider: 'stripe',
211
+ # event: 'charge.succeeded',
212
+ # original_error: ActiveRecord::RecordInvalid.new(...)
213
+ # )
214
+ #
215
+ class PersistenceError < Error
216
+ # @return [Exception, nil] the original exception
217
+ attr_reader :original_error
218
+
219
+ # Initializes a new PersistenceError.
220
+ #
221
+ # @param message [String] the error message
222
+ # @param provider [String, Symbol, nil] the provider name
223
+ # @param event [String, Symbol, nil] the event name
224
+ # @param original_error [Exception, nil] the original exception
225
+ def initialize(message = 'Failed to persist webhook event', provider: nil, event: nil, original_error: nil)
226
+ @original_error = original_error
227
+ super(message, provider:, event:)
228
+ end
229
+ end
230
+
231
+ # Raised when configuration is invalid or incomplete.
232
+ #
233
+ # This error is raised during configuration validation when
234
+ # required settings are missing or invalid.
235
+ #
236
+ # @example
237
+ # raise Hooksmith::ConfigurationError, 'Provider name cannot be blank'
238
+ #
239
+ class ConfigurationError < Error; end
240
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ # Provides idempotency support for webhook processing.
5
+ #
6
+ # Idempotency ensures that processing the same webhook multiple times
7
+ # (due to retries, network issues, etc.) produces the same result and
8
+ # doesn't cause duplicate side effects.
9
+ #
10
+ # @example Configure idempotency key extraction
11
+ # Hooksmith.configure do |config|
12
+ # config.provider(:stripe) do |stripe|
13
+ # stripe.idempotency_key = ->(payload) { payload['id'] }
14
+ # stripe.register(:charge_succeeded, 'StripeChargeProcessor')
15
+ # end
16
+ # end
17
+ #
18
+ # @example Check if event was already processed
19
+ # key = Hooksmith::Idempotency.extract_key(provider: :stripe, payload: payload)
20
+ # if Hooksmith::Idempotency.already_processed?(provider: :stripe, key: key)
21
+ # return # Skip duplicate
22
+ # end
23
+ #
24
+ module Idempotency
25
+ module_function
26
+
27
+ # Extracts an idempotency key from a webhook payload.
28
+ #
29
+ # @param provider [Symbol, String] the provider name
30
+ # @param payload [Hash] the webhook payload
31
+ # @return [String, nil] the idempotency key or nil if not configured
32
+ def extract_key(provider:, payload:)
33
+ extractor = Hooksmith.configuration.idempotency_key_for(provider)
34
+ return nil unless extractor
35
+
36
+ key = extractor.call(payload)
37
+ key&.to_s
38
+ rescue StandardError => e
39
+ Hooksmith.logger.error("Failed to extract idempotency key for #{provider}: #{e.message}")
40
+ nil
41
+ end
42
+
43
+ # Checks if an event with the given idempotency key was already processed.
44
+ #
45
+ # This requires the event store to be enabled and the model to respond to
46
+ # `exists_with_idempotency_key?` or have a `find_by_idempotency_key` method.
47
+ #
48
+ # @param provider [Symbol, String] the provider name
49
+ # @param key [String] the idempotency key
50
+ # @return [Boolean] true if already processed
51
+ def already_processed?(provider:, key:)
52
+ return false if key.nil?
53
+
54
+ config = Hooksmith.configuration.event_store_config
55
+ return false unless config.enabled
56
+
57
+ model_class = config.model_class
58
+ return false unless model_class
59
+
60
+ check_duplicate(model_class, provider.to_s, key)
61
+ rescue StandardError => e
62
+ Hooksmith.logger.error("Failed to check idempotency for #{provider}: #{e.message}")
63
+ false
64
+ end
65
+
66
+ # Generates a composite idempotency key from multiple fields.
67
+ #
68
+ # @param fields [Array<String, Symbol, nil>] the fields to combine
69
+ # @param separator [String] the separator between fields
70
+ # @return [String] the composite key
71
+ def composite_key(*fields, separator: ':')
72
+ fields.compact.map(&:to_s).join(separator)
73
+ end
74
+
75
+ # Common idempotency key extractors for popular webhook providers.
76
+ module Extractors
77
+ # Stripe uses the event ID as the idempotency key.
78
+ STRIPE = ->(payload) { payload['id'] || payload[:id] }
79
+
80
+ # GitHub uses the delivery ID header (must be passed in payload).
81
+ GITHUB = ->(payload) { payload['delivery_id'] || payload[:delivery_id] }
82
+
83
+ # Generic extractor that looks for common ID fields.
84
+ GENERIC = lambda do |payload|
85
+ payload['id'] || payload[:id] ||
86
+ payload['event_id'] || payload[:event_id] ||
87
+ payload['webhook_id'] || payload[:webhook_id]
88
+ end
89
+ end
90
+
91
+ class << self
92
+ private
93
+
94
+ def check_duplicate(model_class, provider, key)
95
+ if model_class.respond_to?(:exists_with_idempotency_key?)
96
+ model_class.exists_with_idempotency_key?(provider:, idempotency_key: key)
97
+ elsif model_class.respond_to?(:find_by_idempotency_key)
98
+ !model_class.find_by_idempotency_key(provider:, idempotency_key: key).nil?
99
+ elsif model_class.respond_to?(:exists?)
100
+ model_class.exists?(provider:, idempotency_key: key)
101
+ else
102
+ false
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ # Provides ActiveSupport::Notifications instrumentation for webhook processing.
5
+ #
6
+ # This module emits events at key points in the webhook lifecycle, enabling
7
+ # metrics collection, tracing, and debugging without modifying core code.
8
+ #
9
+ # @example Subscribe to all Hooksmith events
10
+ # ActiveSupport::Notifications.subscribe(/hooksmith/) do |name, start, finish, id, payload|
11
+ # duration = finish - start
12
+ # Rails.logger.info "#{name} took #{duration}s"
13
+ # end
14
+ #
15
+ # @example Subscribe to specific events
16
+ # ActiveSupport::Notifications.subscribe('dispatch.hooksmith') do |*args|
17
+ # event = ActiveSupport::Notifications::Event.new(*args)
18
+ # StatsD.timing('hooksmith.dispatch', event.duration, tags: ["provider:#{event.payload[:provider]}"])
19
+ # end
20
+ #
21
+ # == Available Events
22
+ #
23
+ # * `dispatch.hooksmith` - Emitted when a webhook is dispatched
24
+ # - payload: { provider:, event:, payload:, processor:, result: }
25
+ #
26
+ # * `process.hooksmith` - Emitted when a processor executes
27
+ # - payload: { provider:, event:, processor:, result: }
28
+ #
29
+ # * `no_processor.hooksmith` - Emitted when no processor matches
30
+ # - payload: { provider:, event: }
31
+ #
32
+ # * `multiple_processors.hooksmith` - Emitted when multiple processors match
33
+ # - payload: { provider:, event:, processor_count: }
34
+ #
35
+ # * `error.hooksmith` - Emitted when an error occurs
36
+ # - payload: { provider:, event:, error:, error_class: }
37
+ #
38
+ module Instrumentation
39
+ NAMESPACE = 'hooksmith'
40
+
41
+ module_function
42
+
43
+ # Instruments a block with the given event name.
44
+ #
45
+ # @param event_name [String] the event name (without namespace)
46
+ # @param payload [Hash] the event payload
47
+ # @yield the block to instrument
48
+ # @return [Object] the result of the block
49
+ def instrument(event_name, payload = {}, &block)
50
+ return yield unless notifications_available?
51
+
52
+ full_name = "#{event_name}.#{NAMESPACE}"
53
+ ActiveSupport::Notifications.instrument(full_name, payload, &block)
54
+ end
55
+
56
+ # Publishes an event without a block.
57
+ #
58
+ # @param event_name [String] the event name (without namespace)
59
+ # @param payload [Hash] the event payload
60
+ def publish(event_name, payload = {})
61
+ return unless notifications_available?
62
+
63
+ full_name = "#{event_name}.#{NAMESPACE}"
64
+ ActiveSupport::Notifications.publish(full_name, payload)
65
+ end
66
+
67
+ # Checks if ActiveSupport::Notifications is available.
68
+ #
69
+ # @return [Boolean] true if available
70
+ def notifications_available?
71
+ defined?(ActiveSupport::Notifications)
72
+ end
73
+
74
+ # Subscribes to a Hooksmith event.
75
+ #
76
+ # @param event_name [String, Regexp, nil] the event name, pattern, or nil for all events
77
+ # @yield [name, start, finish, id, payload] the event callback
78
+ # @return [Object] the subscription object
79
+ def subscribe(event_name = nil, &block)
80
+ return unless notifications_available?
81
+
82
+ pattern = build_subscription_pattern(event_name)
83
+ ActiveSupport::Notifications.subscribe(pattern, &block)
84
+ end
85
+
86
+ # Unsubscribes from a Hooksmith event.
87
+ #
88
+ # @param subscriber [Object] the subscription object from subscribe
89
+ def unsubscribe(subscriber)
90
+ return unless notifications_available?
91
+
92
+ ActiveSupport::Notifications.unsubscribe(subscriber)
93
+ end
94
+
95
+ private
96
+
97
+ # Builds the subscription pattern based on the event name type.
98
+ #
99
+ # @param event_name [String, Regexp, nil] the event name or pattern
100
+ # @return [String, Regexp] the subscription pattern
101
+ def build_subscription_pattern(event_name)
102
+ case event_name
103
+ when nil
104
+ /\.#{NAMESPACE}$/
105
+ when Regexp
106
+ event_name
107
+ else
108
+ "#{event_name}.#{NAMESPACE}"
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ module Jobs
5
+ # ActiveJob for asynchronous webhook processing.
6
+ #
7
+ # This job wraps the Dispatcher to process webhooks in the background,
8
+ # allowing your webhook endpoint to respond quickly while processing
9
+ # happens asynchronously.
10
+ #
11
+ # @example Basic usage in a controller
12
+ # class WebhooksController < ApplicationController
13
+ # def stripe
14
+ # Hooksmith::Jobs::DispatcherJob.perform_later(
15
+ # provider: 'stripe',
16
+ # event: params[:type],
17
+ # payload: params.to_unsafe_h
18
+ # )
19
+ # head :ok
20
+ # end
21
+ # end
22
+ #
23
+ # @example With custom queue
24
+ # Hooksmith::Jobs::DispatcherJob.set(queue: :webhooks).perform_later(...)
25
+ #
26
+ # @example With retry configuration (in your application)
27
+ # # config/initializers/hooksmith.rb
28
+ # Hooksmith::Jobs::DispatcherJob.retry_on StandardError, wait: :polynomially_longer, attempts: 5
29
+ #
30
+ class DispatcherJob < ActiveJob::Base
31
+ queue_as :default
32
+
33
+ # Performs the webhook dispatch asynchronously.
34
+ #
35
+ # @param provider [String, Symbol] the webhook provider name
36
+ # @param event [String, Symbol] the event type
37
+ # @param payload [Hash] the webhook payload
38
+ # @param options [Hash] additional options
39
+ # @option options [Boolean] :skip_idempotency_check (false) skip duplicate checking
40
+ # @return [Object] the result from the processor
41
+ def perform(provider:, event:, payload:, **options)
42
+ provider = provider.to_s
43
+ event = event.to_s
44
+
45
+ if check_idempotency?(options)
46
+ key = Hooksmith::Idempotency.extract_key(provider:, payload:)
47
+ if key && Hooksmith::Idempotency.already_processed?(provider:, key:)
48
+ Hooksmith.logger.info("Skipping duplicate webhook: #{provider}/#{event} (key=#{key})")
49
+ return nil
50
+ end
51
+ end
52
+
53
+ Hooksmith::Dispatcher.new(provider:, event:, payload:).run!
54
+ end
55
+
56
+ private
57
+
58
+ def check_idempotency?(options)
59
+ !options[:skip_idempotency_check]
60
+ end
61
+ end
62
+ end
63
+ end