hooksmith 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +295 -64
- data/lib/hooksmith/config/provider.rb +25 -4
- data/lib/hooksmith/configuration.rb +46 -7
- data/lib/hooksmith/dispatcher.rb +71 -34
- data/lib/hooksmith/errors.rb +240 -0
- data/lib/hooksmith/idempotency.rb +107 -0
- data/lib/hooksmith/instrumentation.rb +112 -0
- data/lib/hooksmith/jobs/dispatcher_job.rb +63 -0
- data/lib/hooksmith/rails/webhooks_controller.rb +152 -0
- data/lib/hooksmith/request.rb +110 -0
- data/lib/hooksmith/verifiers/base.rb +79 -0
- data/lib/hooksmith/verifiers/bearer_token.rb +79 -0
- data/lib/hooksmith/verifiers/hmac.rb +184 -0
- data/lib/hooksmith/version.rb +1 -1
- data/lib/hooksmith.rb +51 -1
- metadata +32 -3
data/lib/hooksmith/dispatcher.rb
CHANGED
|
@@ -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.
|
|
17
|
-
@event = event.
|
|
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
|
-
|
|
32
|
-
|
|
44
|
+
Hooksmith::Instrumentation.instrument('dispatch', provider: @provider, event: @event) do
|
|
45
|
+
dispatch_webhook
|
|
46
|
+
end
|
|
47
|
+
end
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
Hooksmith::
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|