rails_webhook_outbox 0.2.0 → 0.4.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 +218 -4
- data/Rakefile +6 -1
- data/UPGRADING.md +92 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_01_base.css +53 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_02_layout.css +88 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_03_table.css +26 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_04_badges.css +19 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_05_forms.css +42 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_06_filters.css +13 -0
- data/app/assets/stylesheets/rails_webhook_outbox/_07_timeline.css +31 -0
- data/app/controllers/rails_webhook_outbox/base_controller.rb +15 -0
- data/app/controllers/rails_webhook_outbox/deliveries_controller.rb +46 -0
- data/app/controllers/rails_webhook_outbox/events_controller.rb +13 -0
- data/app/controllers/rails_webhook_outbox/overview_controller.rb +7 -0
- data/app/controllers/rails_webhook_outbox/subscriptions_controller.rb +62 -0
- data/app/helpers/rails_webhook_outbox/application_helper.rb +29 -0
- data/app/javascript/rails_webhook_outbox/application.js +2 -0
- data/app/javascript/rails_webhook_outbox/controllers/application.js +4 -0
- data/app/javascript/rails_webhook_outbox/controllers/index.js +4 -0
- data/app/javascript/rails_webhook_outbox/controllers/secret_controller.js +24 -0
- data/app/jobs/rails_webhook_outbox/delivery_job.rb +62 -1
- data/app/models/rails_webhook_outbox/delivery.rb +28 -0
- data/app/models/rails_webhook_outbox/delivery_attempt.rb +13 -0
- data/app/models/rails_webhook_outbox/overview.rb +49 -0
- data/app/models/rails_webhook_outbox/subscription.rb +50 -2
- data/app/views/layouts/rails_webhook_outbox/application.html.erb +22 -0
- data/app/views/rails_webhook_outbox/deliveries/_detail.html.erb +47 -0
- data/app/views/rails_webhook_outbox/deliveries/_row.html.erb +7 -0
- data/app/views/rails_webhook_outbox/deliveries/_timeline.html.erb +41 -0
- data/app/views/rails_webhook_outbox/deliveries/index.html.erb +62 -0
- data/app/views/rails_webhook_outbox/deliveries/retry.turbo_stream.erb +2 -0
- data/app/views/rails_webhook_outbox/deliveries/show.html.erb +5 -0
- data/app/views/rails_webhook_outbox/events/index.html.erb +22 -0
- data/app/views/rails_webhook_outbox/overview/_recent_failures.html.erb +29 -0
- data/app/views/rails_webhook_outbox/overview/_stats.html.erb +19 -0
- data/app/views/rails_webhook_outbox/overview/show.html.erb +8 -0
- data/app/views/rails_webhook_outbox/subscriptions/_form.html.erb +49 -0
- data/app/views/rails_webhook_outbox/subscriptions/edit.html.erb +7 -0
- data/app/views/rails_webhook_outbox/subscriptions/index.html.erb +37 -0
- data/app/views/rails_webhook_outbox/subscriptions/new.html.erb +7 -0
- data/app/views/rails_webhook_outbox/subscriptions/show.html.erb +49 -0
- data/config/importmap.rb +6 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb +1 -1
- data/db/migrate/20260624000002_create_webhook_outbox_deliveries.rb +4 -4
- data/db/migrate/20260629000002_add_idempotency_key_to_webhook_outbox_deliveries.rb +2 -2
- data/db/migrate/20260701000001_add_secret_rotation_to_webhook_outbox_subscriptions.rb +6 -0
- data/db/migrate/20260701000002_add_consecutive_failures_to_webhook_outbox_subscriptions.rb +5 -0
- data/db/migrate/20261027000001_create_webhook_outbox_delivery_attempts.rb +16 -0
- data/lib/generators/rails_webhook_outbox/install/install_generator.rb +1 -19
- data/lib/rails_webhook_outbox/configuration.rb +14 -1
- data/lib/rails_webhook_outbox/engine.rb +16 -0
- data/lib/rails_webhook_outbox/secret_rotation_error.rb +8 -0
- data/lib/rails_webhook_outbox/sender.rb +2 -1
- data/lib/rails_webhook_outbox/signature.rb +2 -2
- data/lib/rails_webhook_outbox/version.rb +1 -1
- data/lib/rails_webhook_outbox.rb +1 -0
- data/lib/tasks/rails_webhook_outbox_tasks.rake +51 -4
- metadata +70 -3
- data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb.tt +0 -21
- data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb.tt +0 -13
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4c853b16c9d48f970ebe2ccb83c7de3e1d6789d93a9e78407f788b6a5f8dd8d
|
|
4
|
+
data.tar.gz: a9a380da628a0f00c0d6d5ac08d2351edbc1e0fc3084b4d73ce6dab7e1f28c44
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 452c56fedabd11ecd4a9388af0fa7de18b4988c60585c4b567a2b28d9657ee5226ba3b492bcab0752cc29be2d843e2c329092c32391e52311e85dfd460c24f85
|
|
7
|
+
data.tar.gz: e464b60386edada571d92ad803f0c074e2b152fb4438b63d4e843b1833ff0d65686074a8fe0cc62d3817b50241d28aebc068d47605443be5cc4c6e5a5defa657
|
data/README.md
CHANGED
|
@@ -17,13 +17,20 @@ A Rails engine for sending outgoing webhooks with HMAC signing, ActiveJob-based
|
|
|
17
17
|
- [Subscriptions](#subscriptions)
|
|
18
18
|
- [Deliveries](#deliveries)
|
|
19
19
|
- [Async Delivery](#async-delivery)
|
|
20
|
+
- [Instrumentation](#instrumentation)
|
|
21
|
+
- [Logging](#logging)
|
|
20
22
|
- [HTTP Request Format](#http-request-format)
|
|
21
23
|
- [HMAC Signing](#hmac-signing)
|
|
24
|
+
- [Secret Rotation](#secret-rotation)
|
|
25
|
+
- [Circuit Breaker](#circuit-breaker)
|
|
26
|
+
- [Dashboard](#dashboard)
|
|
27
|
+
- [Rake Tasks](#rake-tasks)
|
|
22
28
|
- [Usage](#usage)
|
|
23
29
|
- [Manual Dispatch](#manual-dispatch)
|
|
24
30
|
- [Testing](#testing)
|
|
25
31
|
- [Development](#development)
|
|
26
32
|
- [Dummy App](#dummy-app)
|
|
33
|
+
- [Upgrading](#upgrading)
|
|
27
34
|
- [Contributing](#contributing)
|
|
28
35
|
- [License](#license)
|
|
29
36
|
|
|
@@ -41,13 +48,16 @@ And then execute:
|
|
|
41
48
|
$ bundle install
|
|
42
49
|
```
|
|
43
50
|
|
|
44
|
-
Run the install generator:
|
|
51
|
+
Run the install generator, then migrate:
|
|
45
52
|
|
|
46
53
|
```bash
|
|
47
54
|
$ rails generate rails_webhook_outbox:install
|
|
48
55
|
$ rails db:migrate
|
|
49
56
|
```
|
|
50
57
|
|
|
58
|
+
The generator creates `config/initializers/rails_webhook_outbox.rb`. It does not copy any migrations:
|
|
59
|
+
the engine adds its own to your app's migration paths, so `rails db:migrate` creates the tables.
|
|
60
|
+
|
|
51
61
|
## Configuration
|
|
52
62
|
|
|
53
63
|
```ruby
|
|
@@ -66,6 +76,9 @@ RailsWebhookOutbox.configure do |config|
|
|
|
66
76
|
config.request_timeout = 5
|
|
67
77
|
config.delivery_job_queue = :webhooks
|
|
68
78
|
config.max_payload_size = 65_536 # bytes; set to nil or 0 to disable
|
|
79
|
+
config.secret_rotation_grace_period = 24.hours
|
|
80
|
+
config.circuit_breaker_threshold = 10 # consecutive permanent failures before auto-disabling; nil or 0 disables
|
|
81
|
+
config.dashboard_enabled = true # enable the mountable dashboard UI; defaults to true in development
|
|
69
82
|
end
|
|
70
83
|
```
|
|
71
84
|
|
|
@@ -129,6 +142,23 @@ RailsWebhookOutbox::Delivery.delivered # successfully delivered
|
|
|
129
142
|
RailsWebhookOutbox::Delivery.failed # exhausted all retries
|
|
130
143
|
```
|
|
131
144
|
|
|
145
|
+
Every HTTP attempt is also recorded as a `RailsWebhookOutbox::DeliveryAttempt`, so earlier failures
|
|
146
|
+
are kept after a retry succeeds (the delivery itself only stores the latest response):
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
delivery.delivery_attempts.each do |attempt|
|
|
150
|
+
attempt.attempt_number # => 1, 2, 3, ...
|
|
151
|
+
attempt.outcome # => "failed" or "delivered"
|
|
152
|
+
attempt.response_code # => 503
|
|
153
|
+
attempt.response_body # => truncated to 1000 characters
|
|
154
|
+
attempt.duration_ms # => 182
|
|
155
|
+
attempt.created_at
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Attempts are deleted with their delivery, including by `webhook_outbox:cleanup`. Upgrading from a
|
|
160
|
+
version before 0.4.0 requires a migration — see [Upgrading](#upgrading).
|
|
161
|
+
|
|
132
162
|
[Back to top](#table-of-contents)
|
|
133
163
|
|
|
134
164
|
## Async Delivery
|
|
@@ -162,6 +192,69 @@ RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
|
|
|
162
192
|
|
|
163
193
|
[Back to top](#table-of-contents)
|
|
164
194
|
|
|
195
|
+
## Instrumentation
|
|
196
|
+
|
|
197
|
+
`DeliveryJob` publishes `ActiveSupport::Notifications` events you can subscribe to for logging, metrics, or alerting:
|
|
198
|
+
|
|
199
|
+
| Event | When |
|
|
200
|
+
|-------|------|
|
|
201
|
+
| `webhook.delivered.rails_webhook_outbox` | Delivery succeeded (2xx response) |
|
|
202
|
+
| `webhook.failed.rails_webhook_outbox` | All retries exhausted — permanent failure |
|
|
203
|
+
| `webhook.circuit_breaker_tripped.rails_webhook_outbox` | A permanent failure auto-disabled the subscription — see [Circuit Breaker](#circuit-breaker) |
|
|
204
|
+
|
|
205
|
+
`webhook.delivered` / `webhook.failed` payloads include:
|
|
206
|
+
|
|
207
|
+
| Key | Type | Description |
|
|
208
|
+
|-----|------|-------------|
|
|
209
|
+
| `event` | String | Webhook event name, e.g. `"order.created"` |
|
|
210
|
+
| `subscription_id` | Integer | ID of the `Subscription` record |
|
|
211
|
+
| `delivery_id` | Integer | ID of the `Delivery` record |
|
|
212
|
+
| `duration` | Integer | HTTP round-trip time in milliseconds |
|
|
213
|
+
|
|
214
|
+
`webhook.circuit_breaker_tripped` payloads include `subscription_id` and `consecutive_failures`.
|
|
215
|
+
|
|
216
|
+
Subscribe in an initializer:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
ActiveSupport::Notifications.subscribe("webhook.delivered.rails_webhook_outbox") do |*args|
|
|
220
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
221
|
+
Rails.logger.info "[webhook] delivered #{event.payload[:event]} in #{event.payload[:duration]}ms"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
ActiveSupport::Notifications.subscribe("webhook.failed.rails_webhook_outbox") do |*args|
|
|
225
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
226
|
+
Sentry.capture_message("Webhook permanently failed", extra: event.payload)
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
[Back to top](#table-of-contents)
|
|
231
|
+
|
|
232
|
+
## Logging
|
|
233
|
+
|
|
234
|
+
`Sender` and `DeliveryJob` emit structured `Rails.logger` output for every delivery lifecycle event. All lines are prefixed `[RailsWebhookOutbox]` in key=value format, making them easy to grep and compatible with Logfmt-aware log aggregators (Datadog, Papertrail, etc.).
|
|
235
|
+
|
|
236
|
+
| Source | Level | When | Keys logged |
|
|
237
|
+
|--------|-------|------|-------------|
|
|
238
|
+
| `Sender` | `info` | Before each HTTP call | `event`, `key` (idempotency key), `url` |
|
|
239
|
+
| `DeliveryJob` | `info` | Successful delivery | `event`, `delivery_id`, `subscription_id`, `status`, `duration` |
|
|
240
|
+
| `DeliveryJob` | `warn` | Retryable failure | `event`, `delivery_id`, `subscription_id`, `status`, `attempt`, `next_retry_at` |
|
|
241
|
+
| `DeliveryJob` | `error` | Permanent failure (all retries exhausted) | `event`, `delivery_id`, `subscription_id`, `status`, `attempts` |
|
|
242
|
+
| `DeliveryJob` | `warn` | Circuit breaker tripped | `subscription_id`, `consecutive_failures` |
|
|
243
|
+
|
|
244
|
+
Example lines:
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
[RailsWebhookOutbox] attempt event=order.created key=550e8400-e29b-41d4-a716-446655440000 url=https://example.com/webhooks
|
|
248
|
+
[RailsWebhookOutbox] delivered event=order.created delivery_id=1 subscription_id=1 status=200 duration=45ms
|
|
249
|
+
[RailsWebhookOutbox] retry event=order.created delivery_id=1 subscription_id=1 status=503 attempt=1 next_retry_at=2026-07-01T00:00:13Z
|
|
250
|
+
[RailsWebhookOutbox] failed event=order.created delivery_id=1 subscription_id=1 status=503 attempts=3
|
|
251
|
+
[RailsWebhookOutbox] circuit_breaker_tripped subscription_id=1 consecutive_failures=10
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
No configuration is required — logging is always on and respects your application's log level.
|
|
255
|
+
|
|
256
|
+
[Back to top](#table-of-contents)
|
|
257
|
+
|
|
165
258
|
## HTTP Request Format
|
|
166
259
|
|
|
167
260
|
Each webhook delivery is an HTTP POST to the subscription URL with the following headers and body:
|
|
@@ -195,11 +288,14 @@ Every outgoing request includes an `X-Webhook-Signature` header (configurable) c
|
|
|
195
288
|
X-Webhook-Signature: sha256=a1b2c3d4...
|
|
196
289
|
```
|
|
197
290
|
|
|
198
|
-
Subscribers can verify the signature
|
|
291
|
+
Subscribers can verify the signature. The header may contain more than one comma-separated
|
|
292
|
+
`algorithm=digest` pair while a subscription's secret is [rotating](#secret-rotation), so check
|
|
293
|
+
each one and accept the request if any match:
|
|
199
294
|
|
|
200
295
|
```ruby
|
|
201
|
-
expected = RailsWebhookOutbox::Signature.
|
|
202
|
-
|
|
296
|
+
expected = RailsWebhookOutbox::Signature.sign(raw_body, subscription.secret, :sha256)
|
|
297
|
+
signatures = request.headers["X-Webhook-Signature"].to_s.split(",")
|
|
298
|
+
valid = signatures.any? { |sig| Rack::Utils.secure_compare(sig, "sha256=#{expected}") }
|
|
203
299
|
```
|
|
204
300
|
|
|
205
301
|
You can also call the primitives directly:
|
|
@@ -214,6 +310,114 @@ RailsWebhookOutbox::Signature.header_value(payload, secret)
|
|
|
214
310
|
# => "sha256=a1b2c3d4..."
|
|
215
311
|
```
|
|
216
312
|
|
|
313
|
+
`header_value` also accepts an array of secrets, signing the payload with each one and joining the
|
|
314
|
+
results with a comma — this is how dual-secret signing works during [secret rotation](#secret-rotation).
|
|
315
|
+
|
|
316
|
+
[Back to top](#table-of-contents)
|
|
317
|
+
|
|
318
|
+
## Secret Rotation
|
|
319
|
+
|
|
320
|
+
Rotate a subscription's HMAC secret without dropping any webhook deliveries:
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
sub.rotate_secret!
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
This generates a new secret and moves the old one to `previous_secret`, valid until
|
|
327
|
+
`previous_secret_expires_at` (set from `config.secret_rotation_grace_period`, default 24 hours).
|
|
328
|
+
Pass `grace_period:` to override it for a single rotation:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
sub.rotate_secret!(grace_period: 3.days)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Calling `rotate_secret!` again while a previous secret is still within its grace period raises
|
|
335
|
+
`RailsWebhookOutbox::SecretRotationError`, since the schema only holds one previous secret — doing
|
|
336
|
+
so would silently invalidate a secret subscribers may not have finished transitioning to yet. Pass
|
|
337
|
+
`force: true` to rotate anyway and discard it immediately.
|
|
338
|
+
|
|
339
|
+
While `previous_secret` is still active, every outgoing request is signed with **both** secrets —
|
|
340
|
+
`X-Webhook-Signature` carries a comma-separated list of `algorithm=digest` pairs:
|
|
341
|
+
|
|
342
|
+
```
|
|
343
|
+
X-Webhook-Signature: sha256=<new-secret-digest>,sha256=<previous-secret-digest>
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
See [HMAC Signing](#hmac-signing) for how subscribers should verify a header that may contain more
|
|
347
|
+
than one signature.
|
|
348
|
+
|
|
349
|
+
[Back to top](#table-of-contents)
|
|
350
|
+
|
|
351
|
+
## Circuit Breaker
|
|
352
|
+
|
|
353
|
+
Each `Subscription` tracks `consecutive_failures` — how many deliveries in a row have permanently
|
|
354
|
+
failed (all retries exhausted). A successful delivery resets it to zero.
|
|
355
|
+
|
|
356
|
+
Once `consecutive_failures` reaches `config.circuit_breaker_threshold` (default 10), the
|
|
357
|
+
subscription is automatically disabled (`active: false`) so a persistently failing endpoint stops
|
|
358
|
+
being hammered — deliveries already in flight are skipped rather than retried further once their
|
|
359
|
+
subscription is disabled — and `webhook.circuit_breaker_tripped.rails_webhook_outbox` is published:
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
ActiveSupport::Notifications.subscribe("webhook.circuit_breaker_tripped.rails_webhook_outbox") do |*args|
|
|
363
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
364
|
+
Sentry.capture_message("Webhook subscription auto-disabled",
|
|
365
|
+
extra: event.payload.slice(:subscription_id, :consecutive_failures))
|
|
366
|
+
end
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Set `config.circuit_breaker_threshold` to `nil` or `0` to disable auto-disabling entirely. Retryable
|
|
370
|
+
(non-final) failures don't count towards the threshold — only a delivery that has exhausted all
|
|
371
|
+
retries counts as one consecutive failure. Re-enable a tripped subscription with
|
|
372
|
+
`sub.update!(active: true)`; `consecutive_failures` resets to zero immediately on reactivation, so a
|
|
373
|
+
single subsequent failure won't instantly re-trip the breaker.
|
|
374
|
+
|
|
375
|
+
[Back to top](#table-of-contents)
|
|
376
|
+
|
|
377
|
+
## Dashboard
|
|
378
|
+
|
|
379
|
+
Mount the engine to get a browser dashboard for managing subscriptions and browsing deliveries:
|
|
380
|
+
|
|
381
|
+
```ruby
|
|
382
|
+
# config/routes.rb
|
|
383
|
+
mount RailsWebhookOutbox::Engine => "/admin/webhooks"
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The dashboard is gated by `config.dashboard_enabled` (default: `true` in development, `false`
|
|
387
|
+
elsewhere) — set it explicitly to control access, and put the mount behind your own
|
|
388
|
+
authentication/authorization in `routes.rb` (e.g. `constraints AdminConstraint.new do ... end`) since
|
|
389
|
+
the engine itself has no concept of users.
|
|
390
|
+
|
|
391
|
+
Pages:
|
|
392
|
+
|
|
393
|
+
| Page | Purpose |
|
|
394
|
+
|------|---------|
|
|
395
|
+
| `/` | Overview — subscription/delivery counts, recent failures |
|
|
396
|
+
| `/subscriptions` | List, create, and edit subscriptions; enable/disable with one click |
|
|
397
|
+
| `/subscriptions/:id` | Detail — masked signing secret with reveal/copy, rotate-secret button |
|
|
398
|
+
| `/deliveries` | Filterable delivery log (status, event, date range) with live status updates via Turbo Streams |
|
|
399
|
+
| `/deliveries/:id` | Detail — attempt timeline, payload, response, and a manual retry button for failed deliveries |
|
|
400
|
+
| `/events` | Registered events with subscriber counts and a recent payload example |
|
|
401
|
+
|
|
402
|
+
The UI ships its own assets (CSS inlined, JS via importmap) and follows the system light/dark color
|
|
403
|
+
scheme automatically — no asset pipeline configuration is required beyond what a standard Rails 7+
|
|
404
|
+
app already has.
|
|
405
|
+
|
|
406
|
+
[Back to top](#table-of-contents)
|
|
407
|
+
|
|
408
|
+
## Rake Tasks
|
|
409
|
+
|
|
410
|
+
```bash
|
|
411
|
+
bin/rails webhook_outbox:retry_failed # re-enqueue all failed deliveries for retry
|
|
412
|
+
bin/rails webhook_outbox:list_subscriptions # list subscriptions with status, events, and failure count
|
|
413
|
+
bin/rails webhook_outbox:cleanup[7] # delete delivered/failed deliveries older than N days
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
`retry_failed` resets each failed delivery to `pending` and enqueues a `DeliveryJob`; deliveries
|
|
417
|
+
whose subscription has since been disabled are skipped by the job itself, not by the task.
|
|
418
|
+
`cleanup[days]` requires a positive integer argument and only removes deliveries in a terminal
|
|
419
|
+
state (`delivered` or `failed`) — `pending` deliveries are never deleted.
|
|
420
|
+
|
|
217
421
|
[Back to top](#table-of-contents)
|
|
218
422
|
|
|
219
423
|
## Usage
|
|
@@ -384,6 +588,16 @@ RailsWebhookOutbox::Subscription.first.update!(url: "https://webhook.site/your-i
|
|
|
384
588
|
|
|
385
589
|
[Back to top](#table-of-contents)
|
|
386
590
|
|
|
591
|
+
## Upgrading
|
|
592
|
+
|
|
593
|
+
Some releases need action from you, such as a database migration. Read [UPGRADING.md](UPGRADING.md)
|
|
594
|
+
before updating the gem, and check the **BREAKING** entries in the [CHANGELOG](CHANGELOG.md).
|
|
595
|
+
|
|
596
|
+
**0.4.0 requires a new table** (`webhook_outbox_delivery_attempts`): run `bin/rails db:migrate`
|
|
597
|
+
before deploying. See [Upgrading to 0.4.0](UPGRADING.md#upgrading-to-040).
|
|
598
|
+
|
|
599
|
+
[Back to top](#table-of-contents)
|
|
600
|
+
|
|
387
601
|
## Contributing
|
|
388
602
|
|
|
389
603
|
Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_webhook_outbox).
|
data/Rakefile
CHANGED
|
@@ -13,4 +13,9 @@ RuboCop::RakeTask.new(:lint)
|
|
|
13
13
|
Bundler::Audit::Task.new
|
|
14
14
|
RSpec::Core::RakeTask.new(:spec)
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
desc "Run Brakeman static security analysis"
|
|
17
|
+
task :brakeman do
|
|
18
|
+
sh "bundle exec brakeman -q --no-pager --force-scan --skip-files spec/ ."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
task default: [:lint, :'bundle:audit:update', 'bundle:audit:check', :brakeman, :spec]
|
data/UPGRADING.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Upgrading
|
|
2
|
+
|
|
3
|
+
Notes for upgrades that need action from you, newest version first. For everything that changed in a
|
|
4
|
+
release, see the [CHANGELOG](CHANGELOG.md).
|
|
5
|
+
|
|
6
|
+
## Upgrading to 0.4.0
|
|
7
|
+
|
|
8
|
+
**This release has a breaking change: it requires a new database table.** Run the migration _before_
|
|
9
|
+
the 0.4.0 code starts processing deliveries.
|
|
10
|
+
|
|
11
|
+
### What changed
|
|
12
|
+
|
|
13
|
+
`DeliveryJob` now records every HTTP attempt as a `RailsWebhookOutbox::DeliveryAttempt` in the new
|
|
14
|
+
`webhook_outbox_delivery_attempts` table. This is the data behind the delivery timeline in the
|
|
15
|
+
dashboard. Previously only the latest response was kept on the delivery itself.
|
|
16
|
+
|
|
17
|
+
### Steps
|
|
18
|
+
|
|
19
|
+
1. Update the gem:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
bundle update rails_webhook_outbox
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
2. Run the migration. The engine adds its migrations to your app's migration paths, so there is
|
|
26
|
+
nothing to copy:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bin/rails db:migrate
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
3. Confirm it ran:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bin/rails db:migrate:status | grep delivery_attempts
|
|
36
|
+
# up 20261027000001 Create webhook outbox delivery attempts
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
In a deploy pipeline, run `db:migrate` before the new code starts serving requests or running jobs
|
|
40
|
+
(the usual release-phase step on most hosts).
|
|
41
|
+
|
|
42
|
+
### If you ran the install generator on an earlier version
|
|
43
|
+
|
|
44
|
+
Before 0.4.0, `rails generate rails_webhook_outbox:install` also copied the engine's table migrations
|
|
45
|
+
into your app's `db/migrate`. The engine adds those same migrations to your app on its own, so with
|
|
46
|
+
the copies present `db:migrate` fails:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
ActiveRecord::DuplicateMigrationNameError: Multiple migrations have the name CreateWebhookOutboxSubscriptions.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To fix it, delete the copies and migrate. The engine's migrations now skip tables, columns, and
|
|
53
|
+
indexes that already exist, so they are safe to run against a database the copies already built:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
rm db/migrate/*_create_webhook_outbox_subscriptions.rb \
|
|
57
|
+
db/migrate/*_create_webhook_outbox_deliveries.rb
|
|
58
|
+
bin/rails db:migrate
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Commit the deletions. `db:migrate:status` will keep listing the old copied versions as `NO FILE`;
|
|
62
|
+
that is harmless. The run also adds anything the copies lacked, such as the unique index on
|
|
63
|
+
`idempotency_key`, and creates the new `webhook_outbox_delivery_attempts` table.
|
|
64
|
+
|
|
65
|
+
### If you deploy the code without migrating
|
|
66
|
+
|
|
67
|
+
`DeliveryJob` sends the HTTP request first and records the attempt afterwards. Without the table,
|
|
68
|
+
recording the attempt raises `ActiveRecord::StatementInvalid`, so:
|
|
69
|
+
|
|
70
|
+
- the webhook **is still sent** to the subscriber, but the delivery is left `pending` with
|
|
71
|
+
`attempts: 0`, and the engine's `DeliveryError` retry does not apply to this error;
|
|
72
|
+
- every job fails the same way until the migration is run.
|
|
73
|
+
|
|
74
|
+
Run `db:migrate`, then re-enqueue the stuck deliveries:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
RailsWebhookOutbox::Delivery.pending.find_each { |d| RailsWebhookOutbox::DeliveryJob.perform_later(d) }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The subscriber will receive those webhooks a second time. It can deduplicate them with the
|
|
81
|
+
`X-Webhook-Delivery` header, which is stable across attempts. (`webhook_outbox:retry_failed` does not
|
|
82
|
+
help here: it only re-enqueues `failed` deliveries.)
|
|
83
|
+
|
|
84
|
+
### Existing data
|
|
85
|
+
|
|
86
|
+
Deliveries created before the upgrade have no attempt history; it starts with the first attempt made
|
|
87
|
+
after the migration. Nothing is backfilled.
|
|
88
|
+
|
|
89
|
+
### Downgrading
|
|
90
|
+
|
|
91
|
+
Downgrading the gem back to 0.3.0 is safe without rolling the migration back: 0.3.0 never reads the
|
|
92
|
+
table, and the foreign key cascades on delete, so `webhook_outbox:cleanup` keeps working.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--bg: #f8f9fa;
|
|
5
|
+
--surface: #ffffff;
|
|
6
|
+
--border: #dee2e6;
|
|
7
|
+
--text: #212529;
|
|
8
|
+
--muted: #6c757d;
|
|
9
|
+
--accent: #4f46e5;
|
|
10
|
+
--accent-text: #ffffff;
|
|
11
|
+
--danger: #dc3545;
|
|
12
|
+
--success-bg: #d1e7dd;
|
|
13
|
+
--success-text: #0f5132;
|
|
14
|
+
--warning-bg: #fff3cd;
|
|
15
|
+
--warning-text: #664d03;
|
|
16
|
+
--danger-bg: #f8d7da;
|
|
17
|
+
--danger-text: #842029;
|
|
18
|
+
--radius: 6px;
|
|
19
|
+
--shadow: 0 1px 3px rgba(0,0,0,.08);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root {
|
|
24
|
+
--bg: #16181d;
|
|
25
|
+
--surface: #1f2229;
|
|
26
|
+
--border: #333844;
|
|
27
|
+
--text: #e9ecef;
|
|
28
|
+
--muted: #9aa1ac;
|
|
29
|
+
--accent: #7c7ff2;
|
|
30
|
+
--accent-text: #ffffff;
|
|
31
|
+
--danger: #e25563;
|
|
32
|
+
--success-bg: #10321f;
|
|
33
|
+
--success-text: #7fe0a4;
|
|
34
|
+
--warning-bg: #3a2f0b;
|
|
35
|
+
--warning-text: #f0c96b;
|
|
36
|
+
--danger-bg: #3a1216;
|
|
37
|
+
--danger-text: #f0949c;
|
|
38
|
+
--shadow: 0 1px 3px rgba(0,0,0,.4);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
body {
|
|
43
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
44
|
+
font-size: 14px;
|
|
45
|
+
line-height: 1.5;
|
|
46
|
+
color: var(--text);
|
|
47
|
+
background: var(--bg);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
a { color: var(--accent); }
|
|
51
|
+
|
|
52
|
+
.rwo-monospace { font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; font-size: 12px; }
|
|
53
|
+
.rwo-muted { color: var(--muted); font-size: 12px; }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
.rwo-nav {
|
|
2
|
+
display: flex;
|
|
3
|
+
gap: 1.5rem;
|
|
4
|
+
padding: 1rem 2rem;
|
|
5
|
+
background: var(--surface);
|
|
6
|
+
border-bottom: 1px solid var(--border);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.rwo-nav-link {
|
|
10
|
+
color: var(--text);
|
|
11
|
+
text-decoration: none;
|
|
12
|
+
font-weight: 600;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.rwo-nav-link:hover { color: var(--accent); }
|
|
16
|
+
|
|
17
|
+
.rwo-main { padding: 2rem; max-width: 1100px; margin: 0 auto; }
|
|
18
|
+
|
|
19
|
+
.rwo-header { margin-bottom: 1.5rem; }
|
|
20
|
+
|
|
21
|
+
.rwo-header-row {
|
|
22
|
+
display: flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
justify-content: space-between;
|
|
25
|
+
gap: 1rem;
|
|
26
|
+
margin-bottom: 1rem;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.rwo-section-title { margin: 1.5rem 0 0.75rem; font-size: 16px; }
|
|
30
|
+
|
|
31
|
+
.rwo-back { display: inline-block; margin-bottom: 1rem; font-size: 13px; }
|
|
32
|
+
|
|
33
|
+
.rwo-empty { color: var(--muted); padding: 1rem 0; }
|
|
34
|
+
|
|
35
|
+
.rwo-stats-grid {
|
|
36
|
+
display: grid;
|
|
37
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
38
|
+
gap: 1rem;
|
|
39
|
+
margin-bottom: 2rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.rwo-stat {
|
|
43
|
+
background: var(--surface);
|
|
44
|
+
border: 1px solid var(--border);
|
|
45
|
+
border-radius: var(--radius);
|
|
46
|
+
box-shadow: var(--shadow);
|
|
47
|
+
padding: 1rem;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.rwo-stat-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
|
51
|
+
.rwo-stat-value { font-size: 28px; font-weight: 700; }
|
|
52
|
+
|
|
53
|
+
.rwo-detail {
|
|
54
|
+
background: var(--surface);
|
|
55
|
+
border: 1px solid var(--border);
|
|
56
|
+
border-radius: var(--radius);
|
|
57
|
+
box-shadow: var(--shadow);
|
|
58
|
+
padding: 1rem;
|
|
59
|
+
margin-bottom: 1rem;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.rwo-detail-grid {
|
|
63
|
+
display: grid;
|
|
64
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
65
|
+
gap: 1rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.rwo-detail-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
|
69
|
+
.rwo-detail-value { font-size: 15px; }
|
|
70
|
+
|
|
71
|
+
.rwo-code-block {
|
|
72
|
+
background: var(--bg);
|
|
73
|
+
border: 1px solid var(--border);
|
|
74
|
+
border-radius: var(--radius);
|
|
75
|
+
padding: 1rem;
|
|
76
|
+
overflow-x: auto;
|
|
77
|
+
white-space: pre-wrap;
|
|
78
|
+
word-break: break-word;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.rwo-event {
|
|
82
|
+
background: var(--surface);
|
|
83
|
+
border: 1px solid var(--border);
|
|
84
|
+
border-radius: var(--radius);
|
|
85
|
+
box-shadow: var(--shadow);
|
|
86
|
+
padding: 1rem;
|
|
87
|
+
margin-bottom: 1rem;
|
|
88
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
.rwo-table {
|
|
2
|
+
width: 100%;
|
|
3
|
+
border-collapse: collapse;
|
|
4
|
+
background: var(--surface);
|
|
5
|
+
border: 1px solid var(--border);
|
|
6
|
+
border-radius: var(--radius);
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.rwo-table th, .rwo-table td {
|
|
11
|
+
text-align: left;
|
|
12
|
+
padding: 0.6rem 0.75rem;
|
|
13
|
+
border-bottom: 1px solid var(--border);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.rwo-table th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
|
|
17
|
+
|
|
18
|
+
.rwo-table tbody tr:last-child td { border-bottom: none; }
|
|
19
|
+
|
|
20
|
+
.rwo-path { font-weight: 600; }
|
|
21
|
+
|
|
22
|
+
.rwo-pagination {
|
|
23
|
+
display: flex;
|
|
24
|
+
justify-content: space-between;
|
|
25
|
+
margin-top: 1rem;
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.rwo-badge {
|
|
2
|
+
display: inline-block;
|
|
3
|
+
padding: 0.2rem 0.6rem;
|
|
4
|
+
border-radius: 999px;
|
|
5
|
+
font-size: 12px;
|
|
6
|
+
font-weight: 600;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.rwo-badge--delivered { background: var(--success-bg); color: var(--success-text); }
|
|
10
|
+
.rwo-badge--pending { background: var(--warning-bg); color: var(--warning-text); }
|
|
11
|
+
.rwo-badge--failed { background: var(--danger-bg); color: var(--danger-text); }
|
|
12
|
+
|
|
13
|
+
.rwo-alert {
|
|
14
|
+
background: var(--danger-bg);
|
|
15
|
+
color: var(--danger-text);
|
|
16
|
+
border-radius: var(--radius);
|
|
17
|
+
padding: 0.75rem 1rem;
|
|
18
|
+
margin-bottom: 1rem;
|
|
19
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
.rwo-form { max-width: 480px; }
|
|
2
|
+
|
|
3
|
+
.rwo-field { margin-bottom: 1rem; }
|
|
4
|
+
|
|
5
|
+
.rwo-field label { display: block; font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 0.3rem; }
|
|
6
|
+
|
|
7
|
+
.rwo-input, .rwo-select {
|
|
8
|
+
width: 100%;
|
|
9
|
+
padding: 0.5rem 0.6rem;
|
|
10
|
+
border: 1px solid var(--border);
|
|
11
|
+
border-radius: var(--radius);
|
|
12
|
+
background: var(--surface);
|
|
13
|
+
color: var(--text);
|
|
14
|
+
font-size: 14px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.rwo-checkbox-group { display: flex; flex-direction: column; gap: 0.4rem; }
|
|
18
|
+
.rwo-checkbox-label { display: flex; align-items: center; gap: 0.4rem; font-weight: 400; text-transform: none; letter-spacing: normal; }
|
|
19
|
+
|
|
20
|
+
.rwo-btn, .rwo-btn-small, .rwo-btn-danger, .rwo-btn-reset {
|
|
21
|
+
display: inline-block;
|
|
22
|
+
border: none;
|
|
23
|
+
border-radius: var(--radius);
|
|
24
|
+
cursor: pointer;
|
|
25
|
+
font-size: 14px;
|
|
26
|
+
font-weight: 600;
|
|
27
|
+
text-decoration: none;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.rwo-btn { background: var(--accent); color: var(--accent-text); padding: 0.55rem 1rem; }
|
|
31
|
+
.rwo-btn-small { background: var(--surface); color: var(--text); border: 1px solid var(--border); padding: 0.35rem 0.7rem; font-size: 12px; }
|
|
32
|
+
.rwo-btn-danger { background: var(--danger); color: #fff; padding: 0.55rem 1rem; }
|
|
33
|
+
.rwo-btn-reset { background: transparent; color: var(--muted); padding: 0.55rem 0.75rem; }
|
|
34
|
+
|
|
35
|
+
.rwo-secret {
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: 0.6rem;
|
|
39
|
+
margin-bottom: 0.5rem;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.rwo-secret-display { font-size: 14px; }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.rwo-filters {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-wrap: wrap;
|
|
4
|
+
align-items: end;
|
|
5
|
+
gap: 1rem;
|
|
6
|
+
background: var(--surface);
|
|
7
|
+
border: 1px solid var(--border);
|
|
8
|
+
border-radius: var(--radius);
|
|
9
|
+
padding: 1rem;
|
|
10
|
+
margin-bottom: 1.5rem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.rwo-filters .rwo-field { margin-bottom: 0; min-width: 140px; }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
.rwo-timeline {
|
|
2
|
+
list-style: none;
|
|
3
|
+
margin-left: 6px;
|
|
4
|
+
border-left: 2px solid var(--border);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.rwo-timeline-item {
|
|
8
|
+
position: relative;
|
|
9
|
+
padding: 0 0 1rem 1.25rem;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.rwo-timeline-item::before {
|
|
13
|
+
content: "";
|
|
14
|
+
position: absolute;
|
|
15
|
+
left: -7px;
|
|
16
|
+
top: 4px;
|
|
17
|
+
width: 12px;
|
|
18
|
+
height: 12px;
|
|
19
|
+
border-radius: 50%;
|
|
20
|
+
border: 2px solid var(--bg);
|
|
21
|
+
background: var(--muted);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.rwo-timeline-item--delivered::before { background: var(--success-text); }
|
|
25
|
+
.rwo-timeline-item--failed::before { background: var(--danger); }
|
|
26
|
+
.rwo-timeline-item--scheduled::before { background: var(--warning-text); }
|
|
27
|
+
|
|
28
|
+
.rwo-timeline-title { font-weight: 600; }
|
|
29
|
+
|
|
30
|
+
.rwo-timeline-response { margin-top: 0.25rem; }
|
|
31
|
+
.rwo-timeline-response summary { cursor: pointer; }
|