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 +4 -4
- data/README.md +50 -2
- data/app/jobs/rails_webhook_outbox/delivery_job.rb +9 -2
- data/app/models/rails_webhook_outbox/delivery.rb +9 -0
- data/db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb +6 -0
- data/lib/generators/rails_webhook_outbox/install/install_generator.rb +2 -2
- data/lib/generators/rails_webhook_outbox/install/templates/{create_webhook_outbox_deliveries.rb → create_webhook_outbox_deliveries.rb.tt} +2 -1
- data/lib/generators/rails_webhook_outbox/install/templates/initializer.rb +8 -0
- data/lib/rails_webhook_outbox/configuration.rb +3 -1
- data/lib/rails_webhook_outbox/dispatchable.rb +8 -0
- data/lib/rails_webhook_outbox/payload_size_error.rb +7 -0
- data/lib/rails_webhook_outbox/rspec_matchers.rb +32 -0
- data/lib/rails_webhook_outbox/sender.rb +1 -1
- data/lib/rails_webhook_outbox/testing.rb +13 -0
- data/lib/rails_webhook_outbox/version.rb +1 -1
- data/lib/rails_webhook_outbox.rb +26 -0
- metadata +7 -3
- /data/lib/generators/rails_webhook_outbox/install/templates/{create_webhook_outbox_subscriptions.rb → create_webhook_outbox_subscriptions.rb.tt} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 78b2ef77e9c0d4c8e0805566fd1da1e813b5e60163b3032f0584267a925803e3
|
|
4
|
+
data.tar.gz: 9a3154d6f2029879ed18aaf83548e9a950f27a27057c9a3c517363eaca21ca3e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
27
|
+
status: final ? :failed : :pending,
|
|
28
|
+
next_retry_at: final ? nil : Time.current + ((executions**4) + 2).seconds
|
|
22
29
|
)
|
|
23
|
-
raise
|
|
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
|
|
@@ -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,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"] =
|
|
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
|
data/lib/rails_webhook_outbox.rb
CHANGED
|
@@ -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.
|
|
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
|