rails_webhook_outbox 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 743d24ea18d5fad259efd9369fc228866eeb883e6f5911d96d081e304a62aca4
4
- data.tar.gz: 9bd087f6a44e6eedb95b1ccd97fd9e52423c547f67f3d4d384eb5b407fdfee1e
3
+ metadata.gz: 78b2ef77e9c0d4c8e0805566fd1da1e813b5e60163b3032f0584267a925803e3
4
+ data.tar.gz: 9a3154d6f2029879ed18aaf83548e9a950f27a27057c9a3c517363eaca21ca3e
5
5
  SHA512:
6
- metadata.gz: 02b57c5bfb462a475dde3036a6ee5f18fe033faaaa808d03d1bd45417f687162210ad6987aee0135775a5d2298fa989d65ac24c06762201cffcad05bb66938f8
7
- data.tar.gz: 5fdaac46aa8ad0591e5c0837df73d9d6bd10a9af15f02f96415ac45ced02e85608198bc8beed2baaa42449e93986ac3a816b9da0e66ae8ff5ff636c2c6807a1a
6
+ metadata.gz: 4e527d2e705089dc96fbba1f913c15d03a5c1ed54b221c4e26dbd1b97d3aae10ad58dc8f910d1db090d6846438302fbef079c78cac0a58b6bca0b50c7c941b2d
7
+ data.tar.gz: fd935881ea40dc8e68fd3b4df3a240adf2f23b7c60e5ce938865a1f59b2f74e4e3a62123bffa6553428bb52b709d4e289adb5219bcaf75ec64b16ca38ca7c265
data/README.md CHANGED
@@ -21,6 +21,7 @@ A Rails engine for sending outgoing webhooks with HMAC signing, ActiveJob-based
21
21
  - [HMAC Signing](#hmac-signing)
22
22
  - [Usage](#usage)
23
23
  - [Manual Dispatch](#manual-dispatch)
24
+ - [Testing](#testing)
24
25
  - [Development](#development)
25
26
  - [Dummy App](#dummy-app)
26
27
  - [Contributing](#contributing)
@@ -64,9 +65,14 @@ RailsWebhookOutbox.configure do |config|
64
65
  config.retry_backoff = :exponential
65
66
  config.request_timeout = 5
66
67
  config.delivery_job_queue = :webhooks
68
+ config.max_payload_size = 65_536 # bytes; set to nil or 0 to disable
67
69
  end
68
70
  ```
69
71
 
72
+ When `config.events` is set, both `dispatch` and `Dispatchable` callbacks will raise `ArgumentError` if the event name is not in the list. Leave `config.events` empty to skip validation entirely.
73
+
74
+ If the JSON-serialised payload exceeds `config.max_payload_size` bytes, `RailsWebhookOutbox::PayloadSizeError` is raised before any `Delivery` record is created or job enqueued. The default limit is 64 KB (`65_536` bytes).
75
+
70
76
  [Back to top](#table-of-contents)
71
77
 
72
78
  ## Subscriptions
@@ -146,7 +152,7 @@ The job uses polynomial backoff (`:polynomially_longer`) between retries — wai
146
152
  | n (`max_retries`) | non-2xx response | `failed` — no further retry |
147
153
  | any | 2xx response | `delivered` |
148
154
 
149
- Every attempt (success or failure) increments `delivery.attempts` and stores the `response_code` and `response_body`. Successful deliveries also set `delivered_at`.
155
+ Every attempt (success or failure) increments `delivery.attempts` and stores the `response_code` and `response_body`. Successful deliveries also set `delivered_at`. Retryable failures set `next_retry_at` to the estimated time of the next attempt (based on the polynomial formula `executions⁴ + 2` seconds); it is cleared to `nil` once all retries are exhausted.
150
156
 
151
157
  Enqueue a delivery manually:
152
158
 
@@ -175,6 +181,8 @@ X-Webhook-Timestamp: 1719100800
175
181
  }
176
182
  ```
177
183
 
184
+ `X-Webhook-Delivery` is the delivery's `idempotency_key` — a UUID generated once when the `Delivery` record is created and reused on every retry attempt. Subscribers can use this value to deduplicate incoming webhooks.
185
+
178
186
  Non-2xx responses raise `RailsWebhookOutbox::DeliveryError`, which carries `response_code` and `response_body` for logging and retry decisions.
179
187
 
180
188
  [Back to top](#table-of-contents)
@@ -257,12 +265,52 @@ RailsWebhookOutbox.dispatch("payment.completed", {
257
265
  })
258
266
  ```
259
267
 
260
- `dispatch` finds every active `Subscription` that includes the given event, creates a `Delivery` record for each one, and enqueues a `DeliveryJob`. Subscriptions that are inactive or do not subscribe to the event are skipped silently.
268
+ `dispatch` validates the event name against `config.events` (if configured), then finds every active `Subscription` that includes the given event, creates a `Delivery` record for each one, and enqueues a `DeliveryJob`. Subscriptions that are inactive or do not subscribe to the event are skipped silently.
269
+
270
+ You can also validate an event name directly without dispatching:
271
+
272
+ ```ruby
273
+ RailsWebhookOutbox.validate_event!("payment.completed")
274
+ # raises ArgumentError if the event is not in config.events
275
+ ```
261
276
 
262
277
  This is the same delivery pipeline used by `Dispatchable` callbacks, so retries, HMAC signing, and delivery logging all apply.
263
278
 
264
279
  [Back to top](#table-of-contents)
265
280
 
281
+ ## Testing
282
+
283
+ Enable test mode in your `rails_helper.rb` to suppress HTTP calls and DB writes during specs:
284
+
285
+ ```ruby
286
+ require "rails_webhook_outbox/rspec_matchers"
287
+
288
+ RailsWebhookOutbox.configure { |c| c.test_mode = true }
289
+
290
+ RSpec.configure do |config|
291
+ config.before { RailsWebhookOutbox::Testing.clear_deliveries! }
292
+ end
293
+ ```
294
+
295
+ When `test_mode` is `true`, dispatched events are captured in memory instead of creating `Delivery` records or enqueuing jobs. Use the `dispatch_webhook` matcher to assert on them:
296
+
297
+ ```ruby
298
+ expect { order.save! }.to dispatch_webhook("order.created")
299
+ expect { order.save! }.to dispatch_webhook("order.created").with_payload({ id: order.id })
300
+ expect { order.save! }.not_to dispatch_webhook("order.updated")
301
+ ```
302
+
303
+ Inspect captured events directly if needed:
304
+
305
+ ```ruby
306
+ RailsWebhookOutbox::Testing.deliveries
307
+ # => [{ event: "order.created", payload: { "id" => 1, ... } }]
308
+ ```
309
+
310
+ `DeliveryJob` also respects `test_mode` — if a job is enqueued and performed directly in a test, it marks the delivery as `delivered` without making an HTTP call.
311
+
312
+ [Back to top](#table-of-contents)
313
+
266
314
  ## Development
267
315
 
268
316
  ```bash
@@ -4,6 +4,11 @@ module RailsWebhookOutbox
4
4
  retry_on RailsWebhookOutbox::DeliveryError, wait: :polynomially_longer, attempts: :unlimited
5
5
 
6
6
  def perform(delivery)
7
+ if RailsWebhookOutbox.config.test_mode
8
+ delivery.update!(status: :delivered, attempts: delivery.attempts + 1, delivered_at: Time.current)
9
+ return
10
+ end
11
+
7
12
  response = Sender.call(delivery)
8
13
  delivery.update!(
9
14
  status: :delivered,
@@ -14,13 +19,15 @@ module RailsWebhookOutbox
14
19
  )
15
20
  rescue DeliveryError => e
16
21
  max_retries = RailsWebhookOutbox.config.max_retries
22
+ final = executions >= max_retries
17
23
  delivery.update!(
18
24
  response_code: e.response_code,
19
25
  response_body: e.response_body&.truncate(1000),
20
26
  attempts: delivery.attempts + 1,
21
- status: executions >= max_retries ? :failed : :pending
27
+ status: final ? :failed : :pending,
28
+ next_retry_at: final ? nil : Time.current + ((executions**4) + 2).seconds
22
29
  )
23
- raise if executions < max_retries
30
+ raise unless final
24
31
  end
25
32
  end
26
33
  end
@@ -6,9 +6,18 @@ module RailsWebhookOutbox
6
6
 
7
7
  enum :status, { pending: 0, delivered: 1, failed: 2 }
8
8
 
9
+ before_validation :generate_idempotency_key, on: :create
10
+
9
11
  validates :event, presence: true
10
12
  validates :payload, presence: true
13
+ validates :idempotency_key, presence: true
11
14
 
12
15
  scope :retryable, -> { pending }
16
+
17
+ private
18
+
19
+ def generate_idempotency_key
20
+ self.idempotency_key ||= SecureRandom.uuid
21
+ end
13
22
  end
14
23
  end
@@ -0,0 +1,6 @@
1
+ class AddIdempotencyKeyToWebhookOutboxDeliveries < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :webhook_outbox_deliveries, :idempotency_key, :string
4
+ add_index :webhook_outbox_deliveries, :idempotency_key, unique: true
5
+ end
6
+ end
@@ -16,11 +16,11 @@ module RailsWebhookOutbox
16
16
 
17
17
  def copy_migrations
18
18
  migration_template(
19
- "create_webhook_outbox_subscriptions.rb",
19
+ "create_webhook_outbox_subscriptions.rb.tt",
20
20
  "db/migrate/create_webhook_outbox_subscriptions.rb"
21
21
  )
22
22
  migration_template(
23
- "create_webhook_outbox_deliveries.rb",
23
+ "create_webhook_outbox_deliveries.rb.tt",
24
24
  "db/migrate/create_webhook_outbox_deliveries.rb"
25
25
  )
26
26
  end
@@ -8,6 +8,7 @@ class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[<%= ActiveRecord::
8
8
  t.integer :response_code
9
9
  t.text :response_body
10
10
  t.integer :attempts, null: false, default: 0
11
+ t.string :idempotency_key
11
12
  t.datetime :delivered_at
12
13
  t.datetime :next_retry_at
13
14
  t.timestamps
@@ -17,4 +18,4 @@ class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[<%= ActiveRecord::
17
18
  add_index :webhook_outbox_deliveries, :event
18
19
  add_index :webhook_outbox_deliveries, [:subscription_id, :created_at]
19
20
  end
20
- end
21
+ end
@@ -19,4 +19,12 @@ RailsWebhookOutbox.configure do |config|
19
19
 
20
20
  # ActiveJob queue for delivery jobs.
21
21
  config.delivery_job_queue = :webhooks
22
+
23
+ # Maximum payload size in bytes. Raises PayloadSizeError before enqueuing if exceeded.
24
+ # Set to nil or 0 to disable.
25
+ config.max_payload_size = 65_536
26
+
27
+ # Set to true in test environments to suppress HTTP calls and capture dispatched events
28
+ # in RailsWebhookOutbox::Testing.deliveries instead of creating DB records or jobs.
29
+ # config.test_mode = false
22
30
  end
@@ -5,7 +5,7 @@ module RailsWebhookOutbox
5
5
 
6
6
  attr_accessor :events, :signing_algorithm, :signing_header,
7
7
  :max_retries, :retry_backoff, :request_timeout,
8
- :delivery_job_queue
8
+ :delivery_job_queue, :max_payload_size, :test_mode
9
9
 
10
10
  def initialize
11
11
  @events = []
@@ -15,6 +15,8 @@ module RailsWebhookOutbox
15
15
  @retry_backoff = :exponential
16
16
  @request_timeout = 5
17
17
  @delivery_job_queue = :webhooks
18
+ @max_payload_size = 65_536
19
+ @test_mode = false
18
20
  end
19
21
 
20
22
  def signing_algorithm=(value)
@@ -24,7 +24,15 @@ module RailsWebhookOutbox
24
24
  def _dispatch_webhook(event, condition)
25
25
  return if condition && !instance_exec(&condition)
26
26
 
27
+ RailsWebhookOutbox.validate_event!(event)
28
+
27
29
  payload = webhook_payload
30
+ RailsWebhookOutbox.validate_payload_size!(payload)
31
+
32
+ if RailsWebhookOutbox.config.test_mode
33
+ RailsWebhookOutbox::Testing.deliveries << { event: event.to_s, payload: payload }
34
+ return
35
+ end
28
36
 
29
37
  Subscription.active.each do |subscription|
30
38
  next unless subscription.subscribes_to?(event)
@@ -0,0 +1,7 @@
1
+ module RailsWebhookOutbox
2
+ class PayloadSizeError < StandardError
3
+ def initialize(size, max)
4
+ super("Payload too large: #{size} bytes exceeds the #{max}-byte limit")
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,32 @@
1
+ require "rails_webhook_outbox/testing"
2
+
3
+ RSpec::Matchers.define :dispatch_webhook do |expected_event|
4
+ chain :with_payload do |payload|
5
+ @expected_payload = payload
6
+ end
7
+
8
+ match do |block|
9
+ before = RailsWebhookOutbox::Testing.deliveries.dup
10
+ block.call
11
+ @dispatched = RailsWebhookOutbox::Testing.deliveries.drop(before.size)
12
+ @dispatched.any? do |d|
13
+ d[:event] == expected_event.to_s &&
14
+ (@expected_payload.nil? || d[:payload] == @expected_payload)
15
+ end
16
+ end
17
+
18
+ supports_block_expectations
19
+
20
+ failure_message do
21
+ if @dispatched.empty?
22
+ "expected #{expected_event.inspect} webhook to be dispatched, but no webhooks were dispatched"
23
+ else
24
+ events = @dispatched.map { |d| d[:event].inspect }.join(", ")
25
+ "expected #{expected_event.inspect} webhook to be dispatched, but dispatched: #{events}"
26
+ end
27
+ end
28
+
29
+ failure_message_when_negated do
30
+ "expected #{expected_event.inspect} webhook not to be dispatched, but it was"
31
+ end
32
+ end
@@ -37,7 +37,7 @@ module RailsWebhookOutbox
37
37
  req["Content-Type"] = "application/json"
38
38
  req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.secret)
39
39
  req["X-Webhook-Event"] = @delivery.event
40
- req["X-Webhook-Delivery"] = SecureRandom.uuid
40
+ req["X-Webhook-Delivery"] = @delivery.idempotency_key
41
41
  req["X-Webhook-Timestamp"] = Time.now.utc.to_i.to_s
42
42
  req.body = body
43
43
  req
@@ -0,0 +1,13 @@
1
+ module RailsWebhookOutbox
2
+ module Testing
3
+ class << self
4
+ def deliveries
5
+ @deliveries ||= []
6
+ end
7
+
8
+ def clear_deliveries!
9
+ @deliveries = []
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,3 +1,3 @@
1
1
  module RailsWebhookOutbox
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -2,6 +2,8 @@ require "rails_webhook_outbox/version"
2
2
  require "rails_webhook_outbox/configuration"
3
3
  require "rails_webhook_outbox/signature"
4
4
  require "rails_webhook_outbox/delivery_error"
5
+ require "rails_webhook_outbox/payload_size_error"
6
+ require "rails_webhook_outbox/testing"
5
7
  require "rails_webhook_outbox/sender"
6
8
  require "rails_webhook_outbox/dispatchable"
7
9
  require "rails_webhook_outbox/engine"
@@ -22,7 +24,31 @@ module RailsWebhookOutbox
22
24
  @configuration = Configuration.new
23
25
  end
24
26
 
27
+ def validate_event!(event)
28
+ registered = config.events
29
+ return if registered.empty?
30
+ return if registered.include?(event.to_s)
31
+
32
+ raise ArgumentError, "Unknown event #{event.inspect}. Registered events: #{registered.join(", ")}"
33
+ end
34
+
35
+ def validate_payload_size!(payload)
36
+ max = config.max_payload_size
37
+ return unless max && max > 0
38
+
39
+ size = JSON.generate(payload).bytesize
40
+ raise PayloadSizeError.new(size, max) if size > max
41
+ end
42
+
25
43
  def dispatch(event, payload)
44
+ validate_event!(event)
45
+ validate_payload_size!(payload)
46
+
47
+ if config.test_mode
48
+ Testing.deliveries << { event: event.to_s, payload: payload }
49
+ return
50
+ end
51
+
26
52
  Subscription.active.each do |subscription|
27
53
  next unless subscription.subscribes_to?(event)
28
54
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_webhook_outbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -44,17 +44,21 @@ files:
44
44
  - config/routes.rb
45
45
  - db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb
46
46
  - db/migrate/20260624000002_create_webhook_outbox_deliveries.rb
47
+ - db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb
47
48
  - lib/generators/rails_webhook_outbox/install/install_generator.rb
48
- - lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb
49
- - lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb
49
+ - lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt
50
+ - lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt
50
51
  - lib/generators/rails_webhook_outbox/install/templates/initializer.rb
51
52
  - lib/rails_webhook_outbox.rb
52
53
  - lib/rails_webhook_outbox/configuration.rb
53
54
  - lib/rails_webhook_outbox/delivery_error.rb
54
55
  - lib/rails_webhook_outbox/dispatchable.rb
55
56
  - lib/rails_webhook_outbox/engine.rb
57
+ - lib/rails_webhook_outbox/payload_size_error.rb
58
+ - lib/rails_webhook_outbox/rspec_matchers.rb
56
59
  - lib/rails_webhook_outbox/sender.rb
57
60
  - lib/rails_webhook_outbox/signature.rb
61
+ - lib/rails_webhook_outbox/testing.rb
58
62
  - lib/rails_webhook_outbox/version.rb
59
63
  - lib/tasks/rails_webhook_outbox_tasks.rake
60
64
  homepage: https://github.com/eclectic-coding/rails_webhook_outbox