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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<div class="rwo-header">
|
|
2
|
+
<h1>Deliveries</h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<%= form_with url: deliveries_path, method: :get, local: true, class: "rwo-filters" do %>
|
|
6
|
+
<div class="rwo-field">
|
|
7
|
+
<%= label_tag :status, "Status" %>
|
|
8
|
+
<%= select_tag :status,
|
|
9
|
+
options_for_select([["All", ""]] + RailsWebhookOutbox::Delivery.statuses.keys.map { |s| [s.capitalize, s] }, @status),
|
|
10
|
+
class: "rwo-select" %>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<div class="rwo-field">
|
|
14
|
+
<%= label_tag :event, "Event" %>
|
|
15
|
+
<%= select_tag :event,
|
|
16
|
+
options_for_select([["All", ""]] + RailsWebhookOutbox.config.events, @event),
|
|
17
|
+
class: "rwo-select" %>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div class="rwo-field">
|
|
21
|
+
<%= label_tag :from, "From" %>
|
|
22
|
+
<%= date_field_tag :from, @from, class: "rwo-input" %>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="rwo-field">
|
|
26
|
+
<%= label_tag :to, "To" %>
|
|
27
|
+
<%= date_field_tag :to, @to, class: "rwo-input" %>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<%= submit_tag "Filter", class: "rwo-btn" %>
|
|
31
|
+
<%= link_to "Reset", deliveries_path, class: "rwo-btn-reset" %>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<%= turbo_stream_from "rails_webhook_outbox_deliveries" %>
|
|
35
|
+
|
|
36
|
+
<% if @deliveries.empty? %>
|
|
37
|
+
<p class="rwo-empty">No deliveries found.</p>
|
|
38
|
+
<% else %>
|
|
39
|
+
<table class="rwo-table">
|
|
40
|
+
<thead>
|
|
41
|
+
<tr>
|
|
42
|
+
<th>Event</th>
|
|
43
|
+
<th>Subscription</th>
|
|
44
|
+
<th>Status</th>
|
|
45
|
+
<th>Attempts</th>
|
|
46
|
+
<th>Created</th>
|
|
47
|
+
</tr>
|
|
48
|
+
</thead>
|
|
49
|
+
<tbody>
|
|
50
|
+
<%= render partial: "row", collection: @deliveries, as: :delivery %>
|
|
51
|
+
</tbody>
|
|
52
|
+
</table>
|
|
53
|
+
|
|
54
|
+
<div class="rwo-pagination">
|
|
55
|
+
<% if @page > 1 %>
|
|
56
|
+
<%= link_to "← Previous", deliveries_path(request.query_parameters.merge(page: @page - 1)), class: "rwo-btn-small" %>
|
|
57
|
+
<% end %>
|
|
58
|
+
<% if @page * RailsWebhookOutbox::DeliveriesController::PER_PAGE < @total_count %>
|
|
59
|
+
<%= link_to "Next →", deliveries_path(request.query_parameters.merge(page: @page + 1)), class: "rwo-btn-small" %>
|
|
60
|
+
<% end %>
|
|
61
|
+
</div>
|
|
62
|
+
<% end %>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<div class="rwo-header">
|
|
2
|
+
<h1>Registered Events</h1>
|
|
3
|
+
</div>
|
|
4
|
+
|
|
5
|
+
<% if @events.empty? %>
|
|
6
|
+
<p class="rwo-empty">No events registered. Set <code>config.events</code> in your initializer.</p>
|
|
7
|
+
<% else %>
|
|
8
|
+
<% @events.each do |event| %>
|
|
9
|
+
<div class="rwo-event">
|
|
10
|
+
<div class="rwo-header-row">
|
|
11
|
+
<h2 class="rwo-section-title"><%= event[:name] %></h2>
|
|
12
|
+
<span class="rwo-muted"><%= event[:subscriber_count] %> active subscriber<%= "s" unless event[:subscriber_count] == 1 %></span>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<% if event[:example] %>
|
|
16
|
+
<pre class="rwo-monospace rwo-code-block"><%= JSON.pretty_generate(event[:example]) %></pre>
|
|
17
|
+
<% else %>
|
|
18
|
+
<p class="rwo-muted">No deliveries yet for this event.</p>
|
|
19
|
+
<% end %>
|
|
20
|
+
</div>
|
|
21
|
+
<% end %>
|
|
22
|
+
<% end %>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<div id="<%= RailsWebhookOutbox::Overview::FAILURES_TARGET %>">
|
|
2
|
+
<h2 class="rwo-section-title">Recent Failures</h2>
|
|
3
|
+
|
|
4
|
+
<% failures = overview.recent_failures %>
|
|
5
|
+
<% if failures.empty? %>
|
|
6
|
+
<p class="rwo-empty">No failed deliveries.</p>
|
|
7
|
+
<% else %>
|
|
8
|
+
<table class="rwo-table">
|
|
9
|
+
<thead>
|
|
10
|
+
<tr>
|
|
11
|
+
<th>Event</th>
|
|
12
|
+
<th>Subscription</th>
|
|
13
|
+
<th>Attempts</th>
|
|
14
|
+
<th>Created</th>
|
|
15
|
+
</tr>
|
|
16
|
+
</thead>
|
|
17
|
+
<tbody>
|
|
18
|
+
<% failures.each do |delivery| %>
|
|
19
|
+
<tr>
|
|
20
|
+
<td><%= link_to delivery.event, delivery_path(delivery) %></td>
|
|
21
|
+
<td><%= delivery.subscription.url %></td>
|
|
22
|
+
<td class="rwo-muted"><%= delivery.attempts %></td>
|
|
23
|
+
<td class="rwo-muted"><%= delivery.created_at.strftime("%Y-%m-%d %H:%M:%S") %></td>
|
|
24
|
+
</tr>
|
|
25
|
+
<% end %>
|
|
26
|
+
</tbody>
|
|
27
|
+
</table>
|
|
28
|
+
<% end %>
|
|
29
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<div id="<%= RailsWebhookOutbox::Overview::STATS_TARGET %>" class="rwo-stats-grid">
|
|
2
|
+
<div class="rwo-stat">
|
|
3
|
+
<div class="rwo-stat-label">Subscriptions</div>
|
|
4
|
+
<div class="rwo-stat-value"><%= link_to overview.subscription_count, subscriptions_path %></div>
|
|
5
|
+
<div class="rwo-muted"><%= overview.active_subscription_count %> active</div>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="rwo-stat">
|
|
8
|
+
<div class="rwo-stat-label">Delivered</div>
|
|
9
|
+
<div class="rwo-stat-value"><%= link_to overview.delivery_count(:delivered), deliveries_path(status: "delivered") %></div>
|
|
10
|
+
</div>
|
|
11
|
+
<div class="rwo-stat">
|
|
12
|
+
<div class="rwo-stat-label">Pending</div>
|
|
13
|
+
<div class="rwo-stat-value"><%= link_to overview.delivery_count(:pending), deliveries_path(status: "pending") %></div>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="rwo-stat">
|
|
16
|
+
<div class="rwo-stat-label">Failed</div>
|
|
17
|
+
<div class="rwo-stat-value"><%= link_to overview.delivery_count(:failed), deliveries_path(status: "failed") %></div>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<%= form_with model: subscription, url: subscription.persisted? ? subscription_path(subscription) : subscriptions_path, local: true, class: "rwo-form" do |form| %>
|
|
2
|
+
<% if subscription.errors.any? %>
|
|
3
|
+
<div class="rwo-alert">
|
|
4
|
+
<ul>
|
|
5
|
+
<% subscription.errors.full_messages.each do |message| %>
|
|
6
|
+
<li><%= message %></li>
|
|
7
|
+
<% end %>
|
|
8
|
+
</ul>
|
|
9
|
+
</div>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<div class="rwo-field">
|
|
13
|
+
<%= form.label :url %>
|
|
14
|
+
<%= form.text_field :url, class: "rwo-input", placeholder: "https://example.com/webhooks" %>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
<div class="rwo-field">
|
|
18
|
+
<%= form.label :events %>
|
|
19
|
+
<% if RailsWebhookOutbox.config.events.any? %>
|
|
20
|
+
<div class="rwo-checkbox-group">
|
|
21
|
+
<%= hidden_field_tag "subscription[events][]", "", id: nil %>
|
|
22
|
+
<% RailsWebhookOutbox.config.events.each do |event| %>
|
|
23
|
+
<label class="rwo-checkbox-label">
|
|
24
|
+
<%= check_box_tag "subscription[events][]", event, subscription.events.include?(event), id: nil %>
|
|
25
|
+
<%= event %>
|
|
26
|
+
</label>
|
|
27
|
+
<% end %>
|
|
28
|
+
</div>
|
|
29
|
+
<% else %>
|
|
30
|
+
<%= form.text_field :events, name: "subscription[events]", value: subscription.events.join(", "), class: "rwo-input", placeholder: "order.created, order.updated" %>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="rwo-field">
|
|
35
|
+
<%= form.label :description %>
|
|
36
|
+
<%= form.text_field :description, class: "rwo-input" %>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<% if subscription.persisted? %>
|
|
40
|
+
<div class="rwo-field">
|
|
41
|
+
<label class="rwo-checkbox-label">
|
|
42
|
+
<%= form.check_box :active %>
|
|
43
|
+
Active
|
|
44
|
+
</label>
|
|
45
|
+
</div>
|
|
46
|
+
<% end %>
|
|
47
|
+
|
|
48
|
+
<%= form.submit subscription.persisted? ? "Update Subscription" : "Create Subscription", class: "rwo-btn" %>
|
|
49
|
+
<% end %>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<div class="rwo-header rwo-header-row">
|
|
2
|
+
<h1>Subscriptions</h1>
|
|
3
|
+
<%= link_to "New Subscription", new_subscription_path, class: "rwo-btn" %>
|
|
4
|
+
</div>
|
|
5
|
+
|
|
6
|
+
<% if @subscriptions.empty? %>
|
|
7
|
+
<p class="rwo-empty">No subscriptions yet.</p>
|
|
8
|
+
<% else %>
|
|
9
|
+
<table class="rwo-table">
|
|
10
|
+
<thead>
|
|
11
|
+
<tr>
|
|
12
|
+
<th>URL</th>
|
|
13
|
+
<th>Events</th>
|
|
14
|
+
<th>Status</th>
|
|
15
|
+
<th>Failures</th>
|
|
16
|
+
<th></th>
|
|
17
|
+
</tr>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
<% @subscriptions.each do |subscription| %>
|
|
21
|
+
<tr>
|
|
22
|
+
<td><%= link_to subscription.url, subscription_path(subscription), class: "rwo-path" %></td>
|
|
23
|
+
<td class="rwo-muted"><%= subscription.events.join(", ") %></td>
|
|
24
|
+
<td><%= active_badge(subscription.active?) %></td>
|
|
25
|
+
<td class="rwo-muted"><%= subscription.consecutive_failures %></td>
|
|
26
|
+
<td>
|
|
27
|
+
<%= button_to(subscription.active? ? "Disable" : "Enable",
|
|
28
|
+
subscription_path(subscription),
|
|
29
|
+
method: :patch,
|
|
30
|
+
params: { subscription: { active: !subscription.active? } },
|
|
31
|
+
class: "rwo-btn-small") %>
|
|
32
|
+
</td>
|
|
33
|
+
</tr>
|
|
34
|
+
<% end %>
|
|
35
|
+
</tbody>
|
|
36
|
+
</table>
|
|
37
|
+
<% end %>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<%= link_to "← All Subscriptions", subscriptions_path, class: "rwo-back" %>
|
|
2
|
+
|
|
3
|
+
<div class="rwo-header rwo-header-row">
|
|
4
|
+
<h1><%= @subscription.url %></h1>
|
|
5
|
+
<%= link_to "Edit", edit_subscription_path(@subscription), class: "rwo-btn" %>
|
|
6
|
+
</div>
|
|
7
|
+
|
|
8
|
+
<% if params[:rotation_error] %>
|
|
9
|
+
<p class="rwo-alert">Secret rotation already in progress — the previous secret is still within its grace period.</p>
|
|
10
|
+
<% end %>
|
|
11
|
+
|
|
12
|
+
<div class="rwo-detail">
|
|
13
|
+
<div class="rwo-detail-grid">
|
|
14
|
+
<div class="rwo-detail-item">
|
|
15
|
+
<div class="rwo-detail-label">Status</div>
|
|
16
|
+
<div class="rwo-detail-value"><%= active_badge(@subscription.active?) %></div>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="rwo-detail-item">
|
|
19
|
+
<div class="rwo-detail-label">Events</div>
|
|
20
|
+
<div class="rwo-detail-value"><%= @subscription.events.join(", ") %></div>
|
|
21
|
+
</div>
|
|
22
|
+
<div class="rwo-detail-item">
|
|
23
|
+
<div class="rwo-detail-label">Consecutive Failures</div>
|
|
24
|
+
<div class="rwo-detail-value"><%= @subscription.consecutive_failures %></div>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="rwo-detail-item">
|
|
27
|
+
<div class="rwo-detail-label">Description</div>
|
|
28
|
+
<div class="rwo-detail-value"><%= @subscription.description.presence || "—" %></div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<h2 class="rwo-section-title">Signing Secret</h2>
|
|
34
|
+
|
|
35
|
+
<div class="rwo-secret" data-controller="secret" data-secret-value-value="<%= @subscription.secret %>">
|
|
36
|
+
<span class="rwo-secret-display rwo-monospace" data-secret-target="display"><%= masked_secret(@subscription.secret) %></span>
|
|
37
|
+
<button type="button" class="rwo-btn-small" data-action="click->secret#reveal" data-secret-target="revealButton">Reveal</button>
|
|
38
|
+
<button type="button" class="rwo-btn-small" data-action="click->secret#copy" data-secret-target="copyButton">Copy</button>
|
|
39
|
+
<span class="rwo-muted" hidden data-secret-target="copiedHint">Copied!</span>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<% if @subscription.previous_secret_active? %>
|
|
43
|
+
<p class="rwo-muted">Previous secret valid until <%= @subscription.previous_secret_expires_at.strftime("%Y-%m-%d %H:%M:%S") %>.</p>
|
|
44
|
+
<% end %>
|
|
45
|
+
|
|
46
|
+
<%= button_to "Rotate Secret", rotate_secret_subscription_path(@subscription),
|
|
47
|
+
method: :patch,
|
|
48
|
+
class: "rwo-btn-danger",
|
|
49
|
+
form: { data: { turbo_confirm: "Rotate the signing secret? Subscribers must be updated within the grace period." } } %>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
pin "@hotwired/turbo", to: "https://cdn.jsdelivr.net/npm/@hotwired/turbo@8.0.23/dist/turbo.es2017-esm.js"
|
|
2
|
+
pin "@hotwired/stimulus", to: "https://cdn.jsdelivr.net/npm/@hotwired/stimulus@3.2.2/dist/stimulus.js"
|
|
3
|
+
pin "rails_webhook_outbox", to: "rails_webhook_outbox/application.js"
|
|
4
|
+
pin "rails_webhook_outbox/controllers", to: "rails_webhook_outbox/controllers/index.js"
|
|
5
|
+
pin "rails_webhook_outbox/controllers/application", to: "rails_webhook_outbox/controllers/application.js"
|
|
6
|
+
pin "rails_webhook_outbox/controllers/secret_controller", to: "rails_webhook_outbox/controllers/secret_controller.js"
|
data/config/routes.rb
CHANGED
|
@@ -1,2 +1,19 @@
|
|
|
1
1
|
RailsWebhookOutbox::Engine.routes.draw do
|
|
2
|
+
root to: "overview#show"
|
|
3
|
+
|
|
4
|
+
# Explicit :only keeps new/edit routes on Rails 7.2 in api_only host apps,
|
|
5
|
+
# where resources would otherwise omit them.
|
|
6
|
+
resources :subscriptions, only: [:index, :show, :new, :create, :edit, :update, :destroy] do
|
|
7
|
+
member do
|
|
8
|
+
patch :rotate_secret
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
resources :deliveries, only: [:index, :show] do
|
|
13
|
+
member do
|
|
14
|
+
post :retry
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
resources :events, only: [:index]
|
|
2
19
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
class CreateWebhookOutboxSubscriptions < ActiveRecord::Migration[7.2]
|
|
2
2
|
def change
|
|
3
|
-
create_table :webhook_outbox_subscriptions do |t|
|
|
3
|
+
create_table :webhook_outbox_subscriptions, if_not_exists: true do |t|
|
|
4
4
|
t.string :url, null: false
|
|
5
5
|
t.string :secret, null: false
|
|
6
6
|
t.json :events, null: false, default: []
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[7.2]
|
|
2
2
|
def change
|
|
3
|
-
create_table :webhook_outbox_deliveries do |t|
|
|
3
|
+
create_table :webhook_outbox_deliveries, if_not_exists: true do |t|
|
|
4
4
|
t.references :subscription, null: false, foreign_key: { to_table: :webhook_outbox_subscriptions }
|
|
5
5
|
t.string :event, null: false
|
|
6
6
|
t.json :payload, null: false
|
|
@@ -13,8 +13,8 @@ class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[7.2]
|
|
|
13
13
|
t.timestamps
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
add_index :webhook_outbox_deliveries, :status
|
|
17
|
-
add_index :webhook_outbox_deliveries, :event
|
|
18
|
-
add_index :webhook_outbox_deliveries, [:subscription_id, :created_at]
|
|
16
|
+
add_index :webhook_outbox_deliveries, :status, if_not_exists: true
|
|
17
|
+
add_index :webhook_outbox_deliveries, :event, if_not_exists: true
|
|
18
|
+
add_index :webhook_outbox_deliveries, [:subscription_id, :created_at], if_not_exists: true
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
class AddIdempotencyKeyToWebhookOutboxDeliveries < ActiveRecord::Migration[7.2]
|
|
2
2
|
def change
|
|
3
|
-
add_column :webhook_outbox_deliveries, :idempotency_key, :string
|
|
4
|
-
add_index :webhook_outbox_deliveries, :idempotency_key, unique: true
|
|
3
|
+
add_column :webhook_outbox_deliveries, :idempotency_key, :string, if_not_exists: true
|
|
4
|
+
add_index :webhook_outbox_deliveries, :idempotency_key, unique: true, if_not_exists: true
|
|
5
5
|
end
|
|
6
6
|
end
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
class AddSecretRotationToWebhookOutboxSubscriptions < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
add_column :webhook_outbox_subscriptions, :previous_secret, :string, if_not_exists: true
|
|
4
|
+
add_column :webhook_outbox_subscriptions, :previous_secret_expires_at, :datetime, if_not_exists: true
|
|
5
|
+
end
|
|
6
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateWebhookOutboxDeliveryAttempts < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
create_table :webhook_outbox_delivery_attempts, if_not_exists: true do |t|
|
|
4
|
+
t.references :delivery, null: false, index: false,
|
|
5
|
+
foreign_key: { to_table: :webhook_outbox_deliveries, on_delete: :cascade }
|
|
6
|
+
t.integer :attempt_number, null: false
|
|
7
|
+
t.integer :outcome, null: false
|
|
8
|
+
t.integer :response_code
|
|
9
|
+
t.text :response_body
|
|
10
|
+
t.integer :duration_ms
|
|
11
|
+
t.datetime :created_at, null: false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
add_index :webhook_outbox_delivery_attempts, [:delivery_id, :attempt_number], if_not_exists: true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -1,29 +1,11 @@
|
|
|
1
1
|
require "rails/generators"
|
|
2
|
-
require "rails/generators/active_record"
|
|
3
2
|
|
|
4
3
|
module RailsWebhookOutbox
|
|
5
4
|
module Generators
|
|
6
5
|
class InstallGenerator < Rails::Generators::Base
|
|
7
|
-
include Rails::Generators::Migration
|
|
8
|
-
|
|
9
6
|
source_root File.expand_path("templates", __dir__)
|
|
10
7
|
|
|
11
|
-
desc "Creates a RailsWebhookOutbox initializer
|
|
12
|
-
|
|
13
|
-
def self.next_migration_number(dirname)
|
|
14
|
-
ActiveRecord::Generators::Base.next_migration_number(dirname)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def copy_migrations
|
|
18
|
-
migration_template(
|
|
19
|
-
"create_webhook_outbox_subscriptions.rb.tt",
|
|
20
|
-
"db/migrate/create_webhook_outbox_subscriptions.rb"
|
|
21
|
-
)
|
|
22
|
-
migration_template(
|
|
23
|
-
"create_webhook_outbox_deliveries.rb.tt",
|
|
24
|
-
"db/migrate/create_webhook_outbox_deliveries.rb"
|
|
25
|
-
)
|
|
26
|
-
end
|
|
8
|
+
desc "Creates a RailsWebhookOutbox initializer. The engine supplies its own migrations; run `rails db:migrate` afterwards."
|
|
27
9
|
|
|
28
10
|
def create_initializer
|
|
29
11
|
template "initializer.rb", "config/initializers/rails_webhook_outbox.rb"
|
|
@@ -5,7 +5,9 @@ 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, :max_payload_size, :test_mode
|
|
8
|
+
:delivery_job_queue, :max_payload_size, :test_mode,
|
|
9
|
+
:secret_rotation_grace_period, :circuit_breaker_threshold,
|
|
10
|
+
:dashboard_enabled
|
|
9
11
|
|
|
10
12
|
def initialize
|
|
11
13
|
@events = []
|
|
@@ -17,6 +19,9 @@ module RailsWebhookOutbox
|
|
|
17
19
|
@delivery_job_queue = :webhooks
|
|
18
20
|
@max_payload_size = 65_536
|
|
19
21
|
@test_mode = false
|
|
22
|
+
@secret_rotation_grace_period = 24.hours
|
|
23
|
+
@circuit_breaker_threshold = 10
|
|
24
|
+
@dashboard_enabled = Rails.env.development?
|
|
20
25
|
end
|
|
21
26
|
|
|
22
27
|
def signing_algorithm=(value)
|
|
@@ -36,5 +41,13 @@ module RailsWebhookOutbox
|
|
|
36
41
|
|
|
37
42
|
@retry_backoff = value
|
|
38
43
|
end
|
|
44
|
+
|
|
45
|
+
def secret_rotation_grace_period=(value)
|
|
46
|
+
unless value.is_a?(Numeric) && value.positive?
|
|
47
|
+
raise ArgumentError, "secret_rotation_grace_period must be a positive duration"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@secret_rotation_grace_period = value
|
|
51
|
+
end
|
|
39
52
|
end
|
|
40
53
|
end
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
require "turbo-rails"
|
|
2
|
+
require "importmap-rails"
|
|
3
|
+
|
|
1
4
|
module RailsWebhookOutbox
|
|
2
5
|
class Engine < ::Rails::Engine
|
|
3
6
|
isolate_namespace RailsWebhookOutbox
|
|
@@ -10,5 +13,18 @@ module RailsWebhookOutbox
|
|
|
10
13
|
end
|
|
11
14
|
end
|
|
12
15
|
end
|
|
16
|
+
|
|
17
|
+
initializer "rails_webhook_outbox.assets" do |app|
|
|
18
|
+
if app.config.respond_to?(:assets)
|
|
19
|
+
app.config.assets.paths << RailsWebhookOutbox::Engine.root.join("app/javascript")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
initializer "rails_webhook_outbox.importmap", before: "importmap" do |app|
|
|
24
|
+
if app.config.respond_to?(:importmap)
|
|
25
|
+
app.config.importmap.paths << RailsWebhookOutbox::Engine.root.join("config/importmap.rb")
|
|
26
|
+
app.config.importmap.cache_sweepers << RailsWebhookOutbox::Engine.root.join("app/javascript")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
13
29
|
end
|
|
14
30
|
end
|
|
@@ -17,6 +17,7 @@ module RailsWebhookOutbox
|
|
|
17
17
|
uri = URI.parse(@delivery.subscription.url)
|
|
18
18
|
body = build_body
|
|
19
19
|
request = build_request(uri, body)
|
|
20
|
+
Rails.logger.info { "[RailsWebhookOutbox] attempt event=#{@delivery.event} key=#{@delivery.idempotency_key} url=#{uri}" }
|
|
20
21
|
response = execute(uri, request)
|
|
21
22
|
raise DeliveryError.new(response) unless response.is_a?(Net::HTTPSuccess)
|
|
22
23
|
response
|
|
@@ -35,7 +36,7 @@ module RailsWebhookOutbox
|
|
|
35
36
|
def build_request(uri, body)
|
|
36
37
|
req = Net::HTTP::Post.new(uri)
|
|
37
38
|
req["Content-Type"] = "application/json"
|
|
38
|
-
req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.
|
|
39
|
+
req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.signing_secrets)
|
|
39
40
|
req["X-Webhook-Event"] = @delivery.event
|
|
40
41
|
req["X-Webhook-Delivery"] = @delivery.idempotency_key
|
|
41
42
|
req["X-Webhook-Timestamp"] = Time.now.utc.to_i.to_s
|
|
@@ -6,9 +6,9 @@ module RailsWebhookOutbox
|
|
|
6
6
|
OpenSSL::HMAC.hexdigest(algorithm.to_s.upcase, secret, payload)
|
|
7
7
|
end
|
|
8
8
|
|
|
9
|
-
def self.header_value(payload,
|
|
9
|
+
def self.header_value(payload, secrets)
|
|
10
10
|
algorithm = RailsWebhookOutbox.config.signing_algorithm
|
|
11
|
-
"#{algorithm}=#{sign(payload, secret, algorithm)}"
|
|
11
|
+
Array(secrets).map { |secret| "#{algorithm}=#{sign(payload, secret, algorithm)}" }.join(",")
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
data/lib/rails_webhook_outbox.rb
CHANGED
|
@@ -3,6 +3,7 @@ require "rails_webhook_outbox/configuration"
|
|
|
3
3
|
require "rails_webhook_outbox/signature"
|
|
4
4
|
require "rails_webhook_outbox/delivery_error"
|
|
5
5
|
require "rails_webhook_outbox/payload_size_error"
|
|
6
|
+
require "rails_webhook_outbox/secret_rotation_error"
|
|
6
7
|
require "rails_webhook_outbox/testing"
|
|
7
8
|
require "rails_webhook_outbox/sender"
|
|
8
9
|
require "rails_webhook_outbox/dispatchable"
|
|
@@ -1,4 +1,51 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
namespace :webhook_outbox do
|
|
2
|
+
desc "Re-enqueue failed deliveries for retry"
|
|
3
|
+
task retry_failed: :environment do
|
|
4
|
+
deliveries = RailsWebhookOutbox::Delivery.failed
|
|
5
|
+
count = deliveries.count
|
|
6
|
+
|
|
7
|
+
if count.zero?
|
|
8
|
+
puts "No failed deliveries to retry"
|
|
9
|
+
else
|
|
10
|
+
deliveries.find_each do |delivery|
|
|
11
|
+
delivery.update!(status: :pending, next_retry_at: nil)
|
|
12
|
+
RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
|
|
13
|
+
end
|
|
14
|
+
puts "Re-enqueued #{count} failed #{"delivery".pluralize(count)} for retry"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "List all webhook subscriptions"
|
|
19
|
+
task list_subscriptions: :environment do
|
|
20
|
+
subscriptions = RailsWebhookOutbox::Subscription.order(:id)
|
|
21
|
+
|
|
22
|
+
if subscriptions.none?
|
|
23
|
+
puts "No subscriptions found"
|
|
24
|
+
else
|
|
25
|
+
subscriptions.find_each do |subscription|
|
|
26
|
+
puts format(
|
|
27
|
+
"#%d [%s] %s events=%s failures=%d",
|
|
28
|
+
subscription.id,
|
|
29
|
+
subscription.active? ? "active" : "inactive",
|
|
30
|
+
subscription.url,
|
|
31
|
+
subscription.events.join(","),
|
|
32
|
+
subscription.consecutive_failures
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "Delete delivered and failed deliveries older than the given number of days"
|
|
39
|
+
task :cleanup, [:days] => :environment do |_task, args|
|
|
40
|
+
days = args[:days].to_i
|
|
41
|
+
|
|
42
|
+
if days <= 0
|
|
43
|
+
puts "Usage: rake webhook_outbox:cleanup[days] (days must be a positive integer)"
|
|
44
|
+
else
|
|
45
|
+
scope = RailsWebhookOutbox::Delivery.where(status: [:delivered, :failed]).where(created_at: ...days.days.ago)
|
|
46
|
+
count = scope.count
|
|
47
|
+
scope.delete_all
|
|
48
|
+
puts "Deleted #{count} #{"delivery".pluralize(count)} older than #{days} #{"day".pluralize(days)}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|