webhukhs 0.5.0 → 0.7.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,16 +3,36 @@
3
3
  require "active_job/railtie"
4
4
 
5
5
  module Webhukhs
6
+ # Background job that validates and processes a persisted webhook.
6
7
  class ProcessingJob < ActiveJob::Base
8
+ # Raised when the job receives an invalid webhook argument.
9
+ class InvalidWebhookArgument < StandardError; end
10
+
11
+ discard_on ActiveJob::DeserializationError, InvalidWebhookArgument do |job, error|
12
+ Rails.error.report(error, context: {
13
+ job_id: job.job_id,
14
+ arguments: job.arguments.map(&:inspect)
15
+ }, severity: :error)
16
+ end
17
+
18
+ # Runs webhook validation and processing lifecycle.
19
+ #
20
+ # @param webhook [Webhukhs::ReceivedWebhook] webhook record to process
21
+ # @return [void]
7
22
  def perform(webhook)
8
- Rails.error.set_context(webhukhs_handler_module_name: webhook.handler_module_name, **Webhukhs.configuration.error_context)
23
+ raise InvalidWebhookArgument, "ProcessingJob received nil webhook argument" if webhook.nil?
24
+ unless webhook.instance_of?(ReceivedWebhook)
25
+ raise InvalidWebhookArgument, "ProcessingJob expected Webhukhs::ReceivedWebhook, got #{webhook.class}"
26
+ end
9
27
 
10
28
  webhook_details_for_logs = "Webhukhs::ReceivedWebhook#%s (handler: %s)" % [webhook.id, webhook.handler]
29
+
11
30
  webhook.with_lock do
12
31
  unless webhook.received?
13
32
  logger.info { "#{webhook_details_for_logs} is being processed in a different job or has been processed already, skipping." }
14
33
  return
15
34
  end
35
+
16
36
  webhook.processing!
17
37
  end
18
38
 
@@ -25,9 +45,9 @@ module Webhukhs
25
45
  logger.info { "#{webhook_details_for_logs} did not pass validation by the handler. Marking it `failed_validation`." }
26
46
  webhook.failed_validation!
27
47
  end
28
- rescue => e
29
- webhook.error!
30
- raise e
48
+ rescue
49
+ webhook.error! if webhook.respond_to?(:error!)
50
+ raise
31
51
  end
32
52
  end
33
53
  end
@@ -3,12 +3,15 @@
3
3
  require "state_machine_enum"
4
4
 
5
5
  module Webhukhs
6
+ # ActiveRecord model that stores received webhook requests for async processing.
6
7
  class ReceivedWebhook < ActiveRecord::Base
7
8
  self.implicit_order_column = "created_at"
8
9
  self.table_name = "received_webhooks"
9
10
 
10
11
  include StateMachineEnum
11
12
 
13
+ before_create :ensure_id
14
+
12
15
  state_machine_enum :status do |s|
13
16
  s.permit_transition(:received, :processing)
14
17
  s.permit_transition(:processing, :failed_validation)
@@ -23,12 +26,14 @@ module Webhukhs
23
26
  end
24
27
 
25
28
  # Store the pertinent data from an ActionDispatch::Request into the webhook.
26
- # @param [ActionDispatch::Request]
29
+ #
30
+ # @param action_dispatch_request [ActionDispatch::Request] request to persist
31
+ # @return [void]
27
32
  def request=(action_dispatch_request)
28
33
  # Filter out all Rack-specific headers such as "rack.input" and the like. We are
29
34
  # only interested in the actual HTTP headers presented by the webserver. Mostly...
30
- headers = action_dispatch_request.env.filter_map do |(header_name, header_value)|
31
- if header_name.is_a?(String) && header_name.upcase == header_name && header_value.is_a?(String)
35
+ headers = action_dispatch_request.env.filter_map do |header_name, header_value|
36
+ if header_name.instance_of?(String) && header_name.upcase == header_name && header_value.instance_of?(String)
32
37
  [header_name, header_value]
33
38
  end
34
39
  end.to_h
@@ -45,10 +50,10 @@ module Webhukhs
45
50
  # Verify the request body is not too large
46
51
  request_body_io = action_dispatch_request.env.fetch("rack.input")
47
52
  if request_body_io.size > Webhukhs.configuration.request_body_size_limit
48
- raise "Cannot accept the webhook as the request body is larger than #{limit} bytes"
53
+ raise "Cannot accept the webhook as the request body is larger than #{Webhukhs.configuration.request_body_size_limit} bytes"
49
54
  end
50
55
 
51
- write_attribute("body", request_body_io.read.force_encoding(Encoding::BINARY))
56
+ write_attribute("body", request_body_io.read.b)
52
57
  write_attribute("request_headers", headers)
53
58
  ensure
54
59
  request_body_io.rewind
@@ -89,11 +94,33 @@ module Webhukhs
89
94
  #
90
95
  # @return [ActionDispatch::Request]
91
96
  def request
92
- ActionDispatch::Request.new(request_headers.merge!("rack.input" => StringIO.new(body.to_s.b)))
97
+ ActionDispatch::Request.new(request_headers.merge("rack.input" => StringIO.new(body.b)))
93
98
  end
94
99
 
100
+ # Instantiates the configured handler for this webhook.
101
+ #
102
+ # @return [Webhukhs::BaseHandler]
95
103
  def handler
96
104
  handler_module_name.constantize.new
97
105
  end
106
+
107
+ private
108
+
109
+ # Assigns a generated id when database does not auto-increment ids.
110
+ #
111
+ # @return [void]
112
+ def ensure_id
113
+ return if id.present?
114
+ # let DB auto-increment handle it
115
+ return if self.class.columns_hash.fetch("id").type == :integer
116
+
117
+ self.id = if SecureRandom.respond_to?(:uuid_v7)
118
+ # Ruby 3.3+ UUIDv7 keeps inserts more index-friendly than random UUIDv4.
119
+ SecureRandom.uuid_v7
120
+ else
121
+ # Fallback for Rubies without UUIDv7 support.
122
+ SecureRandom.uuid
123
+ end
124
+ end
98
125
  end
99
126
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Webhukhs
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/webhukhs.rb CHANGED
@@ -5,16 +5,25 @@ require_relative "webhukhs/engine"
5
5
  require_relative "webhukhs/jobs/processing_job"
6
6
  require "active_support/core_ext/class/attribute"
7
7
 
8
+ # Public namespace for Webhukhs runtime and configuration.
8
9
  module Webhukhs
10
+ # Returns singleton configuration object.
11
+ #
12
+ # @return [Webhukhs::Configuration]
9
13
  def self.configuration
10
14
  @configuration ||= Configuration.new
11
15
  end
12
16
 
17
+ # Yields global configuration object for setup.
18
+ #
19
+ # @yieldparam configuration [Webhukhs::Configuration]
20
+ # @return [void]
13
21
  def self.configure
14
22
  yield configuration
15
23
  end
16
24
  end
17
25
 
26
+ # Holds runtime configuration for Webhukhs.
18
27
  class Webhukhs::Configuration
19
28
  class_attribute :processing_job_class, default: Webhukhs::ProcessingJob
20
29
  class_attribute :active_handlers, default: {}
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class EnqueueProbeJob
6
+ class << self
7
+ attr_accessor :received_webhook
8
+ end
9
+
10
+ def self.perform_later(webhook)
11
+ self.received_webhook = webhook
12
+ end
13
+ end
14
+
15
+ class HandleProbeHandler < Webhukhs::BaseHandler
16
+ attr_reader :enqueued_webhook
17
+
18
+ def enqueue(webhook)
19
+ @enqueued_webhook = webhook
20
+ end
21
+ end
22
+
23
+ class BaseHandlerTest < ActiveSupport::TestCase
24
+ cover "Webhukhs::BaseHandler*"
25
+
26
+ teardown { Webhukhs::ReceivedWebhook.delete_all }
27
+
28
+ test "process is a no-op by default" do
29
+ assert_nil Webhukhs::BaseHandler.new.process(nil)
30
+ end
31
+
32
+ test "defaults to active, valid and exposing errors" do
33
+ handler = Webhukhs::BaseHandler.new
34
+
35
+ assert_equal true, handler.active?
36
+ assert_equal true, handler.valid?(nil)
37
+ assert_equal true, handler.expose_errors_to_sender?
38
+ end
39
+
40
+ test "generates unique UUID event ids by default" do
41
+ handler = Webhukhs::BaseHandler.new
42
+
43
+ first_id = handler.extract_event_id_from_request(nil)
44
+ second_id = handler.extract_event_id_from_request(nil)
45
+
46
+ assert_match(/\A[0-9a-f-]{36}\z/, first_id)
47
+ assert_match(/\A[0-9a-f-]{36}\z/, second_id)
48
+ refute_equal first_id, second_id
49
+ end
50
+
51
+ test "handle passes the handler class name into received webhooks" do
52
+ captured_attributes = nil
53
+ request = ActionDispatch::Request.new(
54
+ "rack.input" => StringIO.new("{}"),
55
+ "REQUEST_METHOD" => "POST",
56
+ "CONTENT_TYPE" => "application/json",
57
+ "action_dispatch.request.path_parameters" => {}
58
+ )
59
+ webhook = Object.new
60
+ handler = HandleProbeHandler.new
61
+
62
+ def webhook.save!
63
+ end
64
+
65
+ with_overridden_singleton_method(Webhukhs::ReceivedWebhook, :new, ->(**attributes) {
66
+ captured_attributes = attributes
67
+ webhook
68
+ }) do
69
+ handler.handle(request)
70
+
71
+ assert_same webhook, handler.enqueued_webhook
72
+ assert_equal "HandleProbeHandler", captured_attributes.fetch(:handler_module_name)
73
+ end
74
+ end
75
+
76
+ test "handle logs duplicate deliveries" do
77
+ handler = ExtractIdHandler.new
78
+ request_headers = {
79
+ "CONTENT_TYPE" => "application/json",
80
+ "action_dispatch.request.path_parameters" => {}
81
+ }
82
+ payload = {event_id: "duplicate-event"}.to_json
83
+
84
+ first_request = ActionDispatch::Request.new(request_headers.merge("rack.input" => StringIO.new(payload)))
85
+ second_request = ActionDispatch::Request.new(request_headers.merge("rack.input" => StringIO.new(payload)))
86
+
87
+ handler.handle(first_request)
88
+
89
+ with_captured_info_logs(Rails) do |messages|
90
+ handler.handle(second_request)
91
+
92
+ assert_equal ["#{handler.inspect} Webhook duplicate-event is a duplicate delivery and will not be stored."], messages
93
+ end
94
+ end
95
+
96
+ test "enqueue uses configured processing job classes directly" do
97
+ original_job_class = Webhukhs.configuration.processing_job_class
98
+ webhook = Object.new
99
+
100
+ Webhukhs.configuration.processing_job_class = EnqueueProbeJob
101
+ EnqueueProbeJob.received_webhook = nil
102
+
103
+ Webhukhs::BaseHandler.new.enqueue(webhook)
104
+
105
+ assert_same webhook, EnqueueProbeJob.received_webhook
106
+ ensure
107
+ Webhukhs.configuration.processing_job_class = original_job_class
108
+ EnqueueProbeJob.received_webhook = nil
109
+ end
110
+
111
+ test "enqueue constantizes configured processing job class names" do
112
+ original_job_class = Webhukhs.configuration.processing_job_class
113
+ webhook = Object.new
114
+
115
+ Webhukhs.configuration.processing_job_class = "EnqueueProbeJob"
116
+ EnqueueProbeJob.received_webhook = nil
117
+
118
+ Webhukhs::BaseHandler.new.enqueue(webhook)
119
+
120
+ assert_same webhook, EnqueueProbeJob.received_webhook
121
+ ensure
122
+ Webhukhs.configuration.processing_job_class = original_job_class
123
+ EnqueueProbeJob.received_webhook = nil
124
+ end
125
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class SkipDuringProcessingHandler < Webhukhs::BaseHandler
6
+ def process(webhook)
7
+ webhook.skipped!
8
+ end
9
+ end
10
+
11
+ class LoggingHandler < Webhukhs::BaseHandler
12
+ class_attribute :processed_webhook_ids, default: []
13
+
14
+ def self.reset!
15
+ self.processed_webhook_ids = []
16
+ end
17
+
18
+ def to_s = self.class.name
19
+
20
+ def valid?(_request) = true
21
+
22
+ def process(webhook)
23
+ self.class.processed_webhook_ids += [webhook.id]
24
+ end
25
+ end
26
+
27
+ class InvalidLoggingHandler < LoggingHandler
28
+ def valid?(_request) = false
29
+ end
30
+
31
+ class ProcessingJobTest < ActiveJob::TestCase
32
+ cover "Webhukhs::ProcessingJob*"
33
+
34
+ teardown do
35
+ Webhukhs::ReceivedWebhook.delete_all
36
+ LoggingHandler.reset!
37
+ InvalidLoggingHandler.reset!
38
+ end
39
+
40
+ test "discards job and reports error when webhook argument is nil" do
41
+ assert_error_reported(Webhukhs::ProcessingJob::InvalidWebhookArgument) do
42
+ Webhukhs::ProcessingJob.perform_now(nil)
43
+ end
44
+
45
+ assert_no_enqueued_jobs only: Webhukhs::ProcessingJob
46
+ end
47
+
48
+ test "raises a specific error message when webhook argument is nil" do
49
+ error = assert_raises(Webhukhs::ProcessingJob::InvalidWebhookArgument) do
50
+ Webhukhs::ProcessingJob.new.perform(nil)
51
+ end
52
+
53
+ assert_equal "ProcessingJob received nil webhook argument", error.message
54
+ end
55
+
56
+ test "discards job and reports error when webhook argument is not a ReceivedWebhook" do
57
+ assert_error_reported(Webhukhs::ProcessingJob::InvalidWebhookArgument) do
58
+ Webhukhs::ProcessingJob.perform_now("not a webhook")
59
+ end
60
+
61
+ assert_no_enqueued_jobs only: Webhukhs::ProcessingJob
62
+ end
63
+
64
+ test "raises a specific error message when webhook argument is not a ReceivedWebhook" do
65
+ error = assert_raises(Webhukhs::ProcessingJob::InvalidWebhookArgument) do
66
+ Webhukhs::ProcessingJob.new.perform("not a webhook")
67
+ end
68
+
69
+ assert_equal "ProcessingJob expected Webhukhs::ReceivedWebhook, got String", error.message
70
+ end
71
+
72
+ test "processes webhooks under a lock" do
73
+ webhook = Webhukhs::ReceivedWebhook.create!(
74
+ handler_event_id: "lock-test",
75
+ handler_module_name: "WebhookTestHandler",
76
+ status: "received",
77
+ body: {isValid: false}.to_json,
78
+ request_headers: {
79
+ "CONTENT_TYPE" => "application/json",
80
+ "action_dispatch.request.path_parameters" => {}
81
+ }
82
+ )
83
+ with_lock_called = false
84
+
85
+ webhook.define_singleton_method(:with_lock) do |&block|
86
+ with_lock_called = true
87
+ block.call
88
+ end
89
+
90
+ Webhukhs::ProcessingJob.new.perform(webhook)
91
+
92
+ assert_equal true, with_lock_called
93
+ assert_predicate webhook.reload, :failed_validation?
94
+ end
95
+
96
+ test "logs when processing is skipped because the webhook is not received" do
97
+ webhook = Webhukhs::ReceivedWebhook.create!(
98
+ handler_event_id: "skip-log-test",
99
+ handler_module_name: "LoggingHandler",
100
+ status: "processing",
101
+ body: {}.to_json,
102
+ request_headers: {}
103
+ )
104
+ job = Webhukhs::ProcessingJob.new
105
+ details = "Webhukhs::ReceivedWebhook##{webhook.id} (handler: LoggingHandler)"
106
+
107
+ with_captured_info_logs(Webhukhs::ProcessingJob) do |messages|
108
+ job.perform(webhook)
109
+
110
+ assert_equal ["#{details} is being processed in a different job or has been processed already, skipping."], messages
111
+ end
112
+ end
113
+
114
+ test "logs when processing starts and completes" do
115
+ webhook = Webhukhs::ReceivedWebhook.create!(
116
+ handler_event_id: "success-log-test",
117
+ handler_module_name: "LoggingHandler",
118
+ status: "received",
119
+ body: {}.to_json,
120
+ request_headers: {
121
+ "CONTENT_TYPE" => "application/json",
122
+ "action_dispatch.request.path_parameters" => {}
123
+ }
124
+ )
125
+ job = Webhukhs::ProcessingJob.new
126
+ details = "Webhukhs::ReceivedWebhook##{webhook.id} (handler: LoggingHandler)"
127
+
128
+ with_captured_info_logs(Webhukhs::ProcessingJob) do |messages|
129
+ job.perform(webhook)
130
+
131
+ assert_equal ["#{details} starting to process", "#{details} processed"], messages
132
+ end
133
+
134
+ assert_predicate webhook.reload, :processed?
135
+ assert_equal [webhook.id], LoggingHandler.processed_webhook_ids
136
+ end
137
+
138
+ test "logs when validation fails" do
139
+ webhook = Webhukhs::ReceivedWebhook.create!(
140
+ handler_event_id: "failed-validation-log-test",
141
+ handler_module_name: "InvalidLoggingHandler",
142
+ status: "received",
143
+ body: {}.to_json,
144
+ request_headers: {
145
+ "CONTENT_TYPE" => "application/json",
146
+ "action_dispatch.request.path_parameters" => {}
147
+ }
148
+ )
149
+ job = Webhukhs::ProcessingJob.new
150
+ details = "Webhukhs::ReceivedWebhook##{webhook.id} (handler: InvalidLoggingHandler)"
151
+
152
+ with_captured_info_logs(Webhukhs::ProcessingJob) do |messages|
153
+ job.perform(webhook)
154
+
155
+ assert_equal ["#{details} did not pass validation by the handler. Marking it `failed_validation`."], messages
156
+ end
157
+
158
+ assert_predicate webhook.reload, :failed_validation?
159
+ end
160
+
161
+ test "does not overwrite webhook state changed by the handler" do
162
+ webhook = Webhukhs::ReceivedWebhook.create!(
163
+ handler_event_id: "skip-test",
164
+ handler_module_name: "SkipDuringProcessingHandler",
165
+ status: "received",
166
+ body: {}.to_json,
167
+ request_headers: {
168
+ "CONTENT_TYPE" => "application/json",
169
+ "action_dispatch.request.path_parameters" => {}
170
+ }
171
+ )
172
+
173
+ Webhukhs::ProcessingJob.new.perform(webhook)
174
+
175
+ assert_predicate webhook.reload, :skipped?
176
+ end
177
+
178
+ test "discards job and reports error when webhook record has been deleted (DeserializationError)" do
179
+ webhook = Webhukhs::ReceivedWebhook.create!(
180
+ handler_event_id: "deserialization-test",
181
+ handler_module_name: "WebhookTestHandler",
182
+ status: "received",
183
+ body: {isValid: true}.to_json
184
+ )
185
+
186
+ Webhukhs::ProcessingJob.perform_later(webhook)
187
+ webhook.destroy!
188
+
189
+ assert_error_reported(ActiveJob::DeserializationError) do
190
+ perform_enqueued_jobs
191
+ end
192
+
193
+ assert_no_enqueued_jobs only: Webhukhs::ProcessingJob
194
+ end
195
+ end