hooksmith 0.1.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +319 -42
- data/lib/hooksmith/config/event_store.rb +47 -0
- data/lib/hooksmith/config/provider.rb +47 -0
- data/lib/hooksmith/configuration.rb +62 -23
- data/lib/hooksmith/dispatcher.rb +74 -29
- data/lib/hooksmith/errors.rb +240 -0
- data/lib/hooksmith/event_recorder.rb +54 -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 +54 -1
- metadata +35 -4
- data/hooksmith-0.1.0.gem +0 -0
|
@@ -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
|
|
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: {
|
|
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 [
|
|
33
|
+
# @yield [Hooksmith::Config::Provider] a block yielding a Provider object
|
|
20
34
|
def provider(provider_name)
|
|
21
|
-
provider_config =
|
|
35
|
+
provider_config = Hooksmith::Config::Provider.new(provider_name)
|
|
22
36
|
yield(provider_config)
|
|
23
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
# @
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
#
|
|
85
|
+
# Configure event store persistence.
|
|
58
86
|
#
|
|
59
|
-
# @
|
|
60
|
-
# @
|
|
61
|
-
|
|
62
|
-
|
|
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
|
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,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
|
-
|
|
32
|
-
|
|
44
|
+
Hooksmith::Instrumentation.instrument('dispatch', provider: @provider, event: @event) do
|
|
45
|
+
dispatch_webhook
|
|
46
|
+
end
|
|
47
|
+
end
|
|
33
48
|
|
|
34
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
raise MultipleProcessorsError.new(@provider, @event, @payload) if matching_processors.size > 1
|
|
57
|
+
handle_multiple_processors(matching) if matching.size > 1
|
|
47
58
|
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|