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.
@@ -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
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ module Rails
5
+ # A concern for Rails controllers that handle webhooks.
6
+ #
7
+ # This concern provides standardized webhook handling with:
8
+ # - Automatic request verification (if configured)
9
+ # - Consistent response codes (200 for success, 400 for bad request, 500 for errors)
10
+ # - Error logging and instrumentation
11
+ # - Skip CSRF protection for webhook endpoints
12
+ #
13
+ # @example Basic usage
14
+ # class WebhooksController < ApplicationController
15
+ # include Hooksmith::Rails::WebhooksController
16
+ #
17
+ # def stripe
18
+ # handle_webhook(provider: 'stripe', event: params[:type], payload: params.to_unsafe_h)
19
+ # end
20
+ # end
21
+ #
22
+ # @example With custom error handling
23
+ # class WebhooksController < ApplicationController
24
+ # include Hooksmith::Rails::WebhooksController
25
+ #
26
+ # def stripe
27
+ # handle_webhook(provider: 'stripe', event: params[:type], payload: params.to_unsafe_h) do |result|
28
+ # # Custom success handling
29
+ # render json: { processed: true, result: result }
30
+ # end
31
+ # rescue Hooksmith::VerificationError => e
32
+ # render json: { error: 'Invalid signature' }, status: :unauthorized
33
+ # end
34
+ # end
35
+ #
36
+ # @example Async processing with ActiveJob
37
+ # class WebhooksController < ApplicationController
38
+ # include Hooksmith::Rails::WebhooksController
39
+ #
40
+ # def stripe
41
+ # handle_webhook_async(provider: 'stripe', event: params[:type], payload: params.to_unsafe_h)
42
+ # end
43
+ # end
44
+ #
45
+ module WebhooksController
46
+ extend ActiveSupport::Concern
47
+
48
+ included do
49
+ skip_before_action :verify_authenticity_token, raise: false
50
+ before_action :verify_webhook_signature, if: :hooksmith_verification_enabled?
51
+ end
52
+
53
+ # Handles a webhook synchronously.
54
+ #
55
+ # @param provider [String, Symbol] the webhook provider
56
+ # @param event [String, Symbol] the event type
57
+ # @param payload [Hash] the webhook payload
58
+ # @yield [result] optional block for custom success handling
59
+ # @yieldparam result [Object] the result from the processor
60
+ # @return [void]
61
+ def handle_webhook(provider:, event:, payload:)
62
+ result = Hooksmith::Dispatcher.new(provider:, event:, payload:).run!
63
+
64
+ if block_given?
65
+ yield(result)
66
+ else
67
+ head :ok
68
+ end
69
+ rescue Hooksmith::MultipleProcessorsError => e
70
+ Hooksmith.logger.error("Webhook error: #{e.message}")
71
+ head :internal_server_error
72
+ rescue StandardError => e
73
+ Hooksmith.logger.error("Webhook processing failed: #{e.message}")
74
+ head :internal_server_error
75
+ end
76
+
77
+ # Handles a webhook asynchronously using ActiveJob.
78
+ #
79
+ # Requires Hooksmith::Jobs::DispatcherJob to be available.
80
+ #
81
+ # @param provider [String, Symbol] the webhook provider
82
+ # @param event [String, Symbol] the event type
83
+ # @param payload [Hash] the webhook payload
84
+ # @param queue [Symbol, String] the queue to use (default: :default)
85
+ # @return [void]
86
+ def handle_webhook_async(provider:, event:, payload:, queue: :default)
87
+ unless defined?(Hooksmith::Jobs::DispatcherJob)
88
+ raise 'Hooksmith::Jobs::DispatcherJob is not available. Ensure ActiveJob is loaded.'
89
+ end
90
+
91
+ Hooksmith::Jobs::DispatcherJob.set(queue:).perform_later(
92
+ provider: provider.to_s,
93
+ event: event.to_s,
94
+ payload: payload.as_json
95
+ )
96
+
97
+ head :ok
98
+ end
99
+
100
+ private
101
+
102
+ # Verifies the webhook signature using the provider's configured verifier.
103
+ #
104
+ # Override this method to customize verification behavior.
105
+ #
106
+ # @return [void]
107
+ # @raise [Hooksmith::VerificationError] if verification fails
108
+ def verify_webhook_signature
109
+ provider = hooksmith_provider_name
110
+ return unless provider
111
+
112
+ verifier = Hooksmith.configuration.verifier_for(provider)
113
+ return unless verifier&.enabled?
114
+
115
+ hooksmith_request = Hooksmith::Request.new(
116
+ headers: request.headers.to_h,
117
+ body: request.raw_post
118
+ )
119
+
120
+ verifier.verify!(hooksmith_request)
121
+ rescue Hooksmith::VerificationError => e
122
+ Hooksmith.logger.warn("Webhook verification failed for #{provider}: #{e.message}")
123
+ head :unauthorized
124
+ end
125
+
126
+ # Returns the provider name for the current action.
127
+ #
128
+ # Override this method to customize provider detection.
129
+ # By default, uses the action name.
130
+ #
131
+ # @return [String, nil] the provider name
132
+ def hooksmith_provider_name
133
+ action_name
134
+ end
135
+
136
+ # Checks if webhook verification is enabled for the current provider.
137
+ #
138
+ # Override this method to customize when verification runs.
139
+ #
140
+ # @return [Boolean] true if verification should run
141
+ def hooksmith_verification_enabled?
142
+ return false unless Hooksmith.configuration.respond_to?(:verifier_for)
143
+
144
+ provider = hooksmith_provider_name
145
+ return false unless provider
146
+
147
+ verifier = Hooksmith.configuration.verifier_for(provider)
148
+ verifier&.enabled? || false
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ # Wrapper for incoming webhook request data.
5
+ #
6
+ # This class provides a consistent interface for accessing request data
7
+ # regardless of the underlying web framework (Rails, Rack, etc.).
8
+ #
9
+ # @example Creating a request from Rails controller
10
+ # request = Hooksmith::Request.new(
11
+ # headers: request.headers.to_h,
12
+ # body: request.raw_post,
13
+ # method: request.request_method,
14
+ # path: request.path
15
+ # )
16
+ #
17
+ # @example Creating a request from Rack env
18
+ # request = Hooksmith::Request.from_rack_env(env)
19
+ #
20
+ class Request
21
+ # @return [Hash] the request headers
22
+ attr_reader :headers
23
+ # @return [String] the raw request body
24
+ attr_reader :body
25
+ # @return [String] the HTTP method (GET, POST, etc.)
26
+ attr_reader :method
27
+ # @return [String] the request path
28
+ attr_reader :path
29
+ # @return [Hash] the parsed payload (optional, for convenience)
30
+ attr_reader :payload
31
+
32
+ # Initializes a new Request.
33
+ #
34
+ # @param headers [Hash] the request headers
35
+ # @param body [String] the raw request body
36
+ # @param method [String] the HTTP method
37
+ # @param path [String] the request path
38
+ # @param payload [Hash] the parsed payload (optional)
39
+ def initialize(headers:, body:, method: 'POST', path: '/', payload: nil)
40
+ @headers = normalize_headers(headers)
41
+ @body = body.to_s
42
+ @method = method.to_s.upcase
43
+ @path = path.to_s
44
+ @payload = payload
45
+ end
46
+
47
+ # Creates a Request from a Rack environment hash.
48
+ #
49
+ # @param env [Hash] the Rack environment
50
+ # @return [Request] a new Request instance
51
+ def self.from_rack_env(env)
52
+ headers = extract_headers_from_rack(env)
53
+ body = env['rack.input']&.read || ''
54
+ env['rack.input']&.rewind
55
+
56
+ new(
57
+ headers:,
58
+ body:,
59
+ method: env['REQUEST_METHOD'],
60
+ path: env['PATH_INFO']
61
+ )
62
+ end
63
+
64
+ # Gets a header value by name (case-insensitive).
65
+ #
66
+ # @param name [String] the header name
67
+ # @return [String, nil] the header value or nil if not found
68
+ def header(name)
69
+ normalized_name = normalize_header_name(name)
70
+ @headers[normalized_name]
71
+ end
72
+
73
+ # Alias for {#header}.
74
+ #
75
+ # @param name [String] the header name
76
+ # @return [String, nil] the header value or nil if not found
77
+ def [](name)
78
+ header(name)
79
+ end
80
+
81
+ # Extracts headers from a Rack environment hash.
82
+ #
83
+ # @param env [Hash] the Rack environment
84
+ # @return [Hash] the extracted headers
85
+ def self.extract_headers_from_rack(env)
86
+ env.select { |k, _| k.start_with?('HTTP_') || %w[CONTENT_TYPE CONTENT_LENGTH].include?(k) }
87
+ end
88
+ private_class_method :extract_headers_from_rack
89
+
90
+ private
91
+
92
+ # Normalizes headers to use consistent key format.
93
+ #
94
+ # @param headers [Hash] the raw headers
95
+ # @return [Hash] normalized headers
96
+ def normalize_headers(headers)
97
+ return {} unless headers.is_a?(Hash)
98
+
99
+ headers.transform_keys { |k| normalize_header_name(k) }
100
+ end
101
+
102
+ # Normalizes a header name to a consistent format.
103
+ #
104
+ # @param name [String] the header name
105
+ # @return [String] the normalized header name
106
+ def normalize_header_name(name)
107
+ name.to_s.upcase.tr('-', '_').sub(/^HTTP_/, '')
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooksmith
4
+ module Verifiers
5
+ # Base class for webhook request verifiers.
6
+ #
7
+ # Verifiers are responsible for authenticating incoming webhook requests
8
+ # before they are processed. Each provider can have its own verifier
9
+ # configured to handle provider-specific authentication schemes.
10
+ #
11
+ # @abstract Subclass and override {#verify!} to implement custom verification.
12
+ #
13
+ # @example Creating a custom verifier
14
+ # class MyCustomVerifier < Hooksmith::Verifiers::Base
15
+ # def verify!(request)
16
+ # token = request.headers['X-Custom-Token']
17
+ # raise Hooksmith::VerificationError, 'Invalid token' unless valid_token?(token)
18
+ # end
19
+ #
20
+ # private
21
+ #
22
+ # def valid_token?(token)
23
+ # token == @options[:expected_token]
24
+ # end
25
+ # end
26
+ #
27
+ class Base
28
+ # @return [Hash] options passed to the verifier
29
+ attr_reader :options
30
+
31
+ # Initializes the verifier with options.
32
+ #
33
+ # @param options [Hash] verifier-specific options
34
+ def initialize(**options)
35
+ @options = options
36
+ end
37
+
38
+ # Verifies the incoming webhook request.
39
+ #
40
+ # @param request [Hooksmith::Request] the incoming request to verify
41
+ # @raise [Hooksmith::VerificationError] if verification fails
42
+ # @return [void]
43
+ def verify!(request)
44
+ raise NotImplementedError, 'Subclasses must implement #verify!'
45
+ end
46
+
47
+ # Returns whether the verifier is configured and should be used.
48
+ #
49
+ # @return [Boolean] true if the verifier should be applied
50
+ def enabled?
51
+ true
52
+ end
53
+
54
+ protected
55
+
56
+ # Performs a constant-time string comparison to prevent timing attacks.
57
+ #
58
+ # @param expected [String] expected string
59
+ # @param actual [String] actual string
60
+ # @return [Boolean] true if strings are equal
61
+ def secure_compare(expected, actual)
62
+ return false if expected.nil? || actual.nil?
63
+ return false if expected.bytesize != actual.bytesize
64
+
65
+ # Use OpenSSL's secure comparison if available (Ruby 2.5+)
66
+ if OpenSSL.respond_to?(:secure_compare)
67
+ OpenSSL.secure_compare(expected, actual)
68
+ else
69
+ # Fallback to manual constant-time comparison
70
+ left = expected.unpack('C*')
71
+ right = actual.unpack('C*')
72
+ result = 0
73
+ left.zip(right) { |x, y| result |= x ^ y }
74
+ result.zero?
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Hooksmith
6
+ module Verifiers
7
+ # Bearer token webhook verifier.
8
+ #
9
+ # This verifier validates webhook requests using a simple bearer token
10
+ # in the Authorization header or a custom header.
11
+ #
12
+ # @example Basic bearer token verification
13
+ # verifier = Hooksmith::Verifiers::BearerToken.new(
14
+ # token: ENV['WEBHOOK_TOKEN']
15
+ # )
16
+ #
17
+ # @example Custom header
18
+ # verifier = Hooksmith::Verifiers::BearerToken.new(
19
+ # token: ENV['WEBHOOK_TOKEN'],
20
+ # header: 'X-Webhook-Token'
21
+ # )
22
+ #
23
+ class BearerToken < Base
24
+ # Default header for bearer tokens
25
+ DEFAULT_HEADER = 'Authorization'
26
+
27
+ # Initializes the bearer token verifier.
28
+ #
29
+ # @param token [String] the expected token value
30
+ # @param header [String] the header containing the token (default: Authorization)
31
+ # @param strip_bearer_prefix [Boolean] whether to strip 'Bearer ' prefix (default: true)
32
+ def initialize(token:, header: DEFAULT_HEADER, strip_bearer_prefix: true, **options)
33
+ super(**options)
34
+ @token = token
35
+ @header = header
36
+ @strip_bearer_prefix = strip_bearer_prefix
37
+ end
38
+
39
+ # Verifies the bearer token in the request.
40
+ #
41
+ # @param request [Hooksmith::Request] the incoming request
42
+ # @raise [Hooksmith::VerificationError] if verification fails
43
+ # @return [void]
44
+ def verify!(request)
45
+ provided_token = extract_token(request)
46
+
47
+ if provided_token.nil? || provided_token.empty?
48
+ raise VerificationError.new('Missing authentication token', reason: 'missing_token')
49
+ end
50
+
51
+ return if secure_compare(@token, provided_token)
52
+
53
+ raise VerificationError.new('Invalid authentication token', reason: 'invalid_token')
54
+ end
55
+
56
+ # Returns whether the verifier is properly configured.
57
+ #
58
+ # @return [Boolean] true if token is present
59
+ def enabled?
60
+ !@token.nil? && !@token.empty?
61
+ end
62
+
63
+ private
64
+
65
+ # Extracts the token from the request headers.
66
+ #
67
+ # @param request [Hooksmith::Request] the incoming request
68
+ # @return [String, nil] the extracted token
69
+ def extract_token(request)
70
+ raw_token = request.header(@header)
71
+ return nil if raw_token.nil?
72
+
73
+ token = raw_token.to_s.strip
74
+ token = token.sub(/\ABearer\s+/i, '') if @strip_bearer_prefix
75
+ token.empty? ? nil : token
76
+ end
77
+ end
78
+ end
79
+ end