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.
@@ -3,24 +3,41 @@
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
18
+ # @return [Hooksmith::Config::EventStore] configuration for event persistence
19
+ attr_reader :event_store_config
10
20
 
11
21
  def initialize
12
- # 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
13
24
  @registry = Hash.new { |hash, key| hash[key] = [] }
25
+ @verifiers = {}
26
+ @idempotency_keys = {}
27
+ @event_store_config = Hooksmith::Config::EventStore.new
14
28
  end
15
29
 
16
30
  # Groups registrations under a specific provider.
17
31
  #
18
32
  # @param provider_name [Symbol, String] the provider name (e.g., :stripe)
19
- # @yield [ProviderConfig] a block yielding a ProviderConfig object
33
+ # @yield [Hooksmith::Config::Provider] a block yielding a Provider object
20
34
  def provider(provider_name)
21
- provider_config = ProviderConfig.new(provider_name)
35
+ provider_config = Hooksmith::Config::Provider.new(provider_name)
22
36
  yield(provider_config)
23
- 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
24
41
  end
25
42
 
26
43
  # Direct registration of a processor.
@@ -29,7 +46,7 @@ module Hooksmith
29
46
  # @param event [Symbol, String] the event name
30
47
  # @param processor_class_name [String] the processor class name
31
48
  def register_processor(provider, event, processor_class_name)
32
- 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 }
33
50
  end
34
51
 
35
52
  # Returns all processor entries for a given provider and event.
@@ -38,28 +55,50 @@ module Hooksmith
38
55
  # @param event [Symbol, String] the event name
39
56
  # @return [Array<Hash>] the array of matching entries.
40
57
  def processors_for(provider, event)
41
- 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]
42
67
  end
43
- end
44
68
 
45
- # ProviderConfig is used internally by the DSL to collect processor registrations.
46
- class ProviderConfig
47
- # @return [Symbol, String] the provider name.
48
- attr_reader :provider
49
- # @return [Array<Hash>] list of entries registered.
50
- attr_reader :entries
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
51
76
 
52
- def initialize(provider)
53
- @provider = provider
54
- @entries = []
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]
55
83
  end
56
84
 
57
- # Registers a processor for a specific event.
85
+ # Configure event store persistence.
58
86
  #
59
- # @param event [Symbol, String] the event name.
60
- # @param processor_class_name [String] the processor class name.
61
- def register(event, processor_class_name)
62
- entries << { event: event.to_sym, processor: processor_class_name }
87
+ # @yield [Hooksmith::Config::EventStore] a block yielding an EventStore object
88
+ # @example
89
+ # Hooksmith.configure do |config|
90
+ # config.event_store do |store|
91
+ # store.enabled = true
92
+ # store.model_class_name = 'MyApp::WebhookEvent'
93
+ # store.record_timing = :before # or :after, or :both
94
+ # store.mapper = ->(provider:, event:, payload:) {
95
+ # now = Time.respond_to?(:current) ? Time.current : Time.now
96
+ # { provider:, event: event.to_s, payload:, received_at: now }
97
+ # }
98
+ # end
99
+ # end
100
+ def event_store
101
+ yield(@event_store_config)
63
102
  end
64
103
  end
65
104
  end
@@ -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,46 +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
- # Fetch all processors registered for this provider and event.
32
- entries = Hooksmith.configuration.processors_for(@provider, @event)
44
+ Hooksmith::Instrumentation.instrument('dispatch', provider: @provider, event: @event) do
45
+ dispatch_webhook
46
+ end
47
+ end
33
48
 
34
- # Instantiate each processor and filter by condition.
35
- matching_processors = entries.map do |entry|
36
- processor = Object.const_get(entry[:processor]).new(@payload)
37
- processor if processor.can_handle?(@payload)
38
- end.compact
49
+ private
39
50
 
40
- if matching_processors.empty?
41
- Hooksmith.logger.warn("No processor registered for #{@provider} event #{@event} could handle the payload")
42
- return
43
- end
51
+ def dispatch_webhook
52
+ record_event(:before)
53
+ matching = find_matching_processors
54
+
55
+ return handle_no_processor if matching.empty?
44
56
 
45
- # If more than one processor qualifies, raise an error.
46
- raise MultipleProcessorsError.new(@provider, @event, @payload) if matching_processors.size > 1
57
+ handle_multiple_processors(matching) if matching.size > 1
47
58
 
48
- # Exactly one matching processor.
49
- matching_processors.first.process!
59
+ execute_processor(matching.first)
60
+ rescue Hooksmith::Error
61
+ raise
50
62
  rescue StandardError => e
63
+ publish_error(e)
51
64
  Hooksmith.logger.error("Error processing #{@provider} event #{@event}: #{e.message}")
52
65
  raise e
53
66
  end
54
- end
55
67
 
56
- # Raised when multiple processors can handle the same event.
57
- class MultipleProcessorsError < StandardError
58
- # Initializes the error with details about the provider, event, and payload.
59
- #
60
- # @param provider [Symbol] the provider name.
61
- # @param event [Symbol] the event name.
62
- # @param payload [Hash] the webhook payload.
63
- def initialize(provider, event, payload)
64
- super("Multiple processors found for #{provider} event #{event}. Payload: #{payload}")
68
+ def find_matching_processors
69
+ entries = Hooksmith.configuration.processors_for(@provider, @event)
70
+ entries.filter_map do |entry|
71
+ processor = Object.const_get(entry[:processor]).new(@payload)
72
+ processor if processor.can_handle?(@payload)
73
+ end
74
+ end
75
+
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
81
+
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
90
+
91
+ def execute_processor(processor)
92
+ result = Hooksmith::Instrumentation.instrument(
93
+ 'process',
94
+ provider: @provider, event: @event, processor: processor.class.name
95
+ ) { processor.process! }
96
+
97
+ record_event(:after)
98
+ result
99
+ end
100
+
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
+ )
65
110
  end
66
111
  end
67
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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ # Records webhook events to a configurable persistence model.
5
+ #
6
+ # This recorder is resilient: failures to persist are logged and do not
7
+ # impact the main processing flow.
8
+ module EventRecorder
9
+ module_function
10
+
11
+ # Record an event if the event store is enabled.
12
+ #
13
+ # @param provider [Symbol, String]
14
+ # @param event [Symbol, String]
15
+ # @param payload [Hash]
16
+ # @param timing [Symbol] one of :before or :after
17
+ def record!(provider:, event:, payload:, timing: :before)
18
+ config = Hooksmith.configuration.event_store_config
19
+ return unless config.enabled
20
+ return unless record_for_timing?(config, timing)
21
+
22
+ model_class = config.model_class
23
+ unless model_class
24
+ Hooksmith.logger.warn("Event store enabled but model '#{config.model_class_name}' not found")
25
+ return
26
+ end
27
+
28
+ attributes = safe_map(config, provider:, event:, payload:)
29
+ model_class.create!(attributes)
30
+ rescue StandardError => e
31
+ Hooksmith.logger.error("Failed to record webhook event: #{e.message}")
32
+ end
33
+
34
+ # Determine whether to record depending on the configured timing.
35
+ def record_for_timing?(config, timing)
36
+ case config.record_timing
37
+ when :both then true
38
+ when :before then timing == :before
39
+ when :after then timing == :after
40
+ end
41
+ end
42
+ private_class_method :record_for_timing?
43
+
44
+ # Safely map attributes using the configured mapper.
45
+ def safe_map(config, provider:, event:, payload:)
46
+ mapper = config.mapper
47
+ mapper.call(provider:, event:, payload:)
48
+ rescue StandardError => e
49
+ Hooksmith.logger.error("Event mapper raised: #{e.message}")
50
+ {}
51
+ end
52
+ private_class_method :safe_map
53
+ end
54
+ 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