webhukhs 0.6.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,35 +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.
7
9
  class InvalidWebhookArgument < StandardError; end
8
10
 
9
- discard_on ActiveJob::DeserializationError do |job, error|
10
- Rails.error.report(error, context: {
11
- job_id: job.job_id,
12
- arguments: job.arguments.map(&:inspect)
13
- }, severity: :error)
14
- end
15
-
16
- discard_on InvalidWebhookArgument do |job, error|
11
+ discard_on ActiveJob::DeserializationError, InvalidWebhookArgument do |job, error|
17
12
  Rails.error.report(error, context: {
18
13
  job_id: job.job_id,
19
14
  arguments: job.arguments.map(&:inspect)
20
15
  }, severity: :error)
21
16
  end
22
17
 
18
+ # Runs webhook validation and processing lifecycle.
19
+ #
20
+ # @param webhook [Webhukhs::ReceivedWebhook] webhook record to process
21
+ # @return [void]
23
22
  def perform(webhook)
24
23
  raise InvalidWebhookArgument, "ProcessingJob received nil webhook argument" if webhook.nil?
25
- unless webhook.is_a?(Webhukhs::ReceivedWebhook)
24
+ unless webhook.instance_of?(ReceivedWebhook)
26
25
  raise InvalidWebhookArgument, "ProcessingJob expected Webhukhs::ReceivedWebhook, got #{webhook.class}"
27
26
  end
28
27
 
29
28
  webhook_details_for_logs = "Webhukhs::ReceivedWebhook#%s (handler: %s)" % [webhook.id, webhook.handler]
29
+
30
30
  webhook.with_lock do
31
31
  unless webhook.received?
32
32
  logger.info { "#{webhook_details_for_logs} is being processed in a different job or has been processed already, skipping." }
33
33
  return
34
34
  end
35
+
35
36
  webhook.processing!
36
37
  end
37
38
 
@@ -44,9 +45,9 @@ module Webhukhs
44
45
  logger.info { "#{webhook_details_for_logs} did not pass validation by the handler. Marking it `failed_validation`." }
45
46
  webhook.failed_validation!
46
47
  end
47
- rescue => e
48
- webhook.error!
49
- raise e
48
+ rescue
49
+ webhook.error! if webhook.respond_to?(:error!)
50
+ raise
50
51
  end
51
52
  end
52
53
  end
@@ -3,6 +3,7 @@
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"
@@ -25,12 +26,14 @@ module Webhukhs
25
26
  end
26
27
 
27
28
  # Store the pertinent data from an ActionDispatch::Request into the webhook.
28
- # @param [ActionDispatch::Request]
29
+ #
30
+ # @param action_dispatch_request [ActionDispatch::Request] request to persist
31
+ # @return [void]
29
32
  def request=(action_dispatch_request)
30
33
  # Filter out all Rack-specific headers such as "rack.input" and the like. We are
31
34
  # only interested in the actual HTTP headers presented by the webserver. Mostly...
32
- headers = action_dispatch_request.env.filter_map do |(header_name, header_value)|
33
- 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)
34
37
  [header_name, header_value]
35
38
  end
36
39
  end.to_h
@@ -47,10 +50,10 @@ module Webhukhs
47
50
  # Verify the request body is not too large
48
51
  request_body_io = action_dispatch_request.env.fetch("rack.input")
49
52
  if request_body_io.size > Webhukhs.configuration.request_body_size_limit
50
- 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"
51
54
  end
52
55
 
53
- write_attribute("body", request_body_io.read.force_encoding(Encoding::BINARY))
56
+ write_attribute("body", request_body_io.read.b)
54
57
  write_attribute("request_headers", headers)
55
58
  ensure
56
59
  request_body_io.rewind
@@ -91,25 +94,31 @@ module Webhukhs
91
94
  #
92
95
  # @return [ActionDispatch::Request]
93
96
  def request
94
- 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)))
95
98
  end
96
99
 
100
+ # Instantiates the configured handler for this webhook.
101
+ #
102
+ # @return [Webhukhs::BaseHandler]
97
103
  def handler
98
104
  handler_module_name.constantize.new
99
105
  end
100
106
 
101
107
  private
102
108
 
109
+ # Assigns a generated id when database does not auto-increment ids.
110
+ #
111
+ # @return [void]
103
112
  def ensure_id
104
113
  return if id.present?
105
114
  # let DB auto-increment handle it
106
- return if self.class.columns_hash["id"].type == :integer
115
+ return if self.class.columns_hash.fetch("id").type == :integer
107
116
 
108
117
  self.id = if SecureRandom.respond_to?(:uuid_v7)
109
- # Requires Ruby 3.3+. This is preferred approach for time-sortable id's.
118
+ # Ruby 3.3+ UUIDv7 keeps inserts more index-friendly than random UUIDv4.
110
119
  SecureRandom.uuid_v7
111
120
  else
112
- # Ruby < 3.2 fallback - uuid v4
121
+ # Fallback for Rubies without UUIDv7 support.
113
122
  SecureRandom.uuid
114
123
  end
115
124
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Webhukhs
4
- VERSION = "0.6.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
@@ -1,10 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "test_helper"
4
- require_relative "test_app"
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
5
30
 
6
31
  class ProcessingJobTest < ActiveJob::TestCase
7
- teardown { Webhukhs::ReceivedWebhook.delete_all }
32
+ cover "Webhukhs::ProcessingJob*"
33
+
34
+ teardown do
35
+ Webhukhs::ReceivedWebhook.delete_all
36
+ LoggingHandler.reset!
37
+ InvalidLoggingHandler.reset!
38
+ end
8
39
 
9
40
  test "discards job and reports error when webhook argument is nil" do
10
41
  assert_error_reported(Webhukhs::ProcessingJob::InvalidWebhookArgument) do
@@ -14,6 +45,14 @@ class ProcessingJobTest < ActiveJob::TestCase
14
45
  assert_no_enqueued_jobs only: Webhukhs::ProcessingJob
15
46
  end
16
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
+
17
56
  test "discards job and reports error when webhook argument is not a ReceivedWebhook" do
18
57
  assert_error_reported(Webhukhs::ProcessingJob::InvalidWebhookArgument) do
19
58
  Webhukhs::ProcessingJob.perform_now("not a webhook")
@@ -22,6 +61,120 @@ class ProcessingJobTest < ActiveJob::TestCase
22
61
  assert_no_enqueued_jobs only: Webhukhs::ProcessingJob
23
62
  end
24
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
+
25
178
  test "discards job and reports error when webhook record has been deleted (DeserializationError)" do
26
179
  webhook = Webhukhs::ReceivedWebhook.create!(
27
180
  handler_event_id: "deserialization-test",