rails_webhook_outbox 0.1.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +349 -0
- data/Rakefile +16 -0
- data/app/controllers/rails_webhook_outbox/application_controller.rb +4 -0
- data/app/jobs/rails_webhook_outbox/application_job.rb +4 -0
- data/app/jobs/rails_webhook_outbox/delivery_job.rb +26 -0
- data/app/mailers/rails_webhook_outbox/application_mailer.rb +6 -0
- data/app/models/rails_webhook_outbox/application_record.rb +5 -0
- data/app/models/rails_webhook_outbox/delivery.rb +14 -0
- data/app/models/rails_webhook_outbox/subscription.rb +27 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb +13 -0
- data/db/migrate/20260624000002_create_webhook_outbox_deliveries.rb +20 -0
- data/lib/generators/rails_webhook_outbox/install/install_generator.rb +33 -0
- data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb +20 -0
- data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb +13 -0
- data/lib/generators/rails_webhook_outbox/install/templates/initializer.rb +22 -0
- data/lib/rails_webhook_outbox/configuration.rb +38 -0
- data/lib/rails_webhook_outbox/delivery_error.rb +11 -0
- data/lib/rails_webhook_outbox/dispatchable.rb +42 -0
- data/lib/rails_webhook_outbox/engine.rb +14 -0
- data/lib/rails_webhook_outbox/sender.rb +58 -0
- data/lib/rails_webhook_outbox/signature.rb +14 -0
- data/lib/rails_webhook_outbox/version.rb +3 -0
- data/lib/rails_webhook_outbox.rb +39 -0
- data/lib/tasks/rails_webhook_outbox_tasks.rake +4 -0
- metadata +85 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 743d24ea18d5fad259efd9369fc228866eeb883e6f5911d96d081e304a62aca4
|
|
4
|
+
data.tar.gz: 9bd087f6a44e6eedb95b1ccd97fd9e52423c547f67f3d4d384eb5b407fdfee1e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 02b57c5bfb462a475dde3036a6ee5f18fe033faaaa808d03d1bd45417f687162210ad6987aee0135775a5d2298fa989d65ac24c06762201cffcad05bb66938f8
|
|
7
|
+
data.tar.gz: 5fdaac46aa8ad0591e5c0837df73d9d6bd10a9af15f02f96415ac45ced02e85608198bc8beed2baaa42449e93986ac3a816b9da0e66ae8ff5ff636c2c6807a1a
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Chuck Smith
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# RailsWebhookOutbox
|
|
2
|
+
|
|
3
|
+
[](https://github.com/eclectic-coding/rails_webhook_outbox/actions/workflows/main.yml)
|
|
4
|
+
[](https://rubygems.org/gems/rails_webhook_outbox)
|
|
5
|
+
[](https://rubygems.org/gems/rails_webhook_outbox)
|
|
6
|
+
[](https://www.ruby-lang.org)
|
|
7
|
+
[](https://rubyonrails.org)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
[](https://codecov.io/gh/eclectic-coding/rails_webhook_outbox)
|
|
10
|
+
|
|
11
|
+
A Rails engine for sending outgoing webhooks with HMAC signing, ActiveJob-based retry, and delivery logging.
|
|
12
|
+
|
|
13
|
+
## Table of Contents
|
|
14
|
+
|
|
15
|
+
- [Installation](#installation)
|
|
16
|
+
- [Configuration](#configuration)
|
|
17
|
+
- [Subscriptions](#subscriptions)
|
|
18
|
+
- [Deliveries](#deliveries)
|
|
19
|
+
- [Async Delivery](#async-delivery)
|
|
20
|
+
- [HTTP Request Format](#http-request-format)
|
|
21
|
+
- [HMAC Signing](#hmac-signing)
|
|
22
|
+
- [Usage](#usage)
|
|
23
|
+
- [Manual Dispatch](#manual-dispatch)
|
|
24
|
+
- [Development](#development)
|
|
25
|
+
- [Dummy App](#dummy-app)
|
|
26
|
+
- [Contributing](#contributing)
|
|
27
|
+
- [License](#license)
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
Add this line to your application's Gemfile:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
gem "rails_webhook_outbox"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
And then execute:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
$ bundle install
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Run the install generator:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
$ rails generate rails_webhook_outbox:install
|
|
47
|
+
$ rails db:migrate
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
# config/initializers/rails_webhook_outbox.rb
|
|
54
|
+
RailsWebhookOutbox.configure do |config|
|
|
55
|
+
config.events = %w[
|
|
56
|
+
order.created
|
|
57
|
+
order.updated
|
|
58
|
+
user.signed_up
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
config.signing_algorithm = :sha256
|
|
62
|
+
config.signing_header = "X-Webhook-Signature"
|
|
63
|
+
config.max_retries = 8
|
|
64
|
+
config.retry_backoff = :exponential
|
|
65
|
+
config.request_timeout = 5
|
|
66
|
+
config.delivery_job_queue = :webhooks
|
|
67
|
+
end
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
[Back to top](#table-of-contents)
|
|
71
|
+
|
|
72
|
+
## Subscriptions
|
|
73
|
+
|
|
74
|
+
A `RailsWebhookOutbox::Subscription` represents an endpoint that receives webhook events.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
sub = RailsWebhookOutbox::Subscription.create!(
|
|
78
|
+
url: "https://example.com/webhooks",
|
|
79
|
+
events: ["order.created", "order.updated"]
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
sub.secret # => "a3f9..." (auto-generated 64-char hex string)
|
|
83
|
+
sub.active? # => true (default)
|
|
84
|
+
sub.subscribes_to?("order.created") # => true
|
|
85
|
+
sub.subscribes_to?("payment.failed") # => false
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Use the `active` scope to find enabled subscriptions:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
RailsWebhookOutbox::Subscription.active
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Disable a subscription by setting `active: false`:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
sub.update!(active: false)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
[Back to top](#table-of-contents)
|
|
101
|
+
|
|
102
|
+
## Deliveries
|
|
103
|
+
|
|
104
|
+
A `RailsWebhookOutbox::Delivery` records each attempt to send a webhook event to a subscription endpoint.
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
delivery = RailsWebhookOutbox::Delivery.create!(
|
|
108
|
+
subscription: subscription,
|
|
109
|
+
event: "order.created",
|
|
110
|
+
payload: { id: 42, total: "99.00" }
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
delivery.pending? # => true (default)
|
|
114
|
+
delivery.delivered!
|
|
115
|
+
delivery.delivered? # => true
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Filter deliveries by status:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
RailsWebhookOutbox::Delivery.retryable # pending — awaiting delivery or retry
|
|
122
|
+
RailsWebhookOutbox::Delivery.delivered # successfully delivered
|
|
123
|
+
RailsWebhookOutbox::Delivery.failed # exhausted all retries
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
[Back to top](#table-of-contents)
|
|
127
|
+
|
|
128
|
+
## Async Delivery
|
|
129
|
+
|
|
130
|
+
`RailsWebhookOutbox::DeliveryJob` handles HTTP dispatch for each `Delivery` record. It is an `ActiveJob` subclass, so it works with any queue backend (Sidekiq, Solid Queue, GoodJob, etc.).
|
|
131
|
+
|
|
132
|
+
Configure the queue and retry behaviour in the initializer:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
RailsWebhookOutbox.configure do |config|
|
|
136
|
+
config.delivery_job_queue = :webhooks # default
|
|
137
|
+
config.max_retries = 8 # default
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The job uses polynomial backoff (`:polynomially_longer`) between retries — wait time grows with each attempt. On each failed attempt it updates the delivery record before re-raising so progress is always persisted:
|
|
142
|
+
|
|
143
|
+
| Execution | Outcome | Delivery status |
|
|
144
|
+
|-----------|---------|-----------------|
|
|
145
|
+
| 1–(n-1) | non-2xx response | `pending` — will retry |
|
|
146
|
+
| n (`max_retries`) | non-2xx response | `failed` — no further retry |
|
|
147
|
+
| any | 2xx response | `delivered` |
|
|
148
|
+
|
|
149
|
+
Every attempt (success or failure) increments `delivery.attempts` and stores the `response_code` and `response_body`. Successful deliveries also set `delivered_at`.
|
|
150
|
+
|
|
151
|
+
Enqueue a delivery manually:
|
|
152
|
+
|
|
153
|
+
```ruby
|
|
154
|
+
RailsWebhookOutbox::DeliveryJob.perform_later(delivery)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
[Back to top](#table-of-contents)
|
|
158
|
+
|
|
159
|
+
## HTTP Request Format
|
|
160
|
+
|
|
161
|
+
Each webhook delivery is an HTTP POST to the subscription URL with the following headers and body:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
POST https://example.com/webhooks
|
|
165
|
+
Content-Type: application/json
|
|
166
|
+
X-Webhook-Signature: sha256=a1b2c3d4...
|
|
167
|
+
X-Webhook-Event: order.created
|
|
168
|
+
X-Webhook-Delivery: 550e8400-e29b-41d4-a716-446655440000
|
|
169
|
+
X-Webhook-Timestamp: 1719100800
|
|
170
|
+
|
|
171
|
+
{
|
|
172
|
+
"event": "order.created",
|
|
173
|
+
"delivered_at": "2026-06-26T10:00:00Z",
|
|
174
|
+
"data": { "id": 42, "total": "99.00" }
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Non-2xx responses raise `RailsWebhookOutbox::DeliveryError`, which carries `response_code` and `response_body` for logging and retry decisions.
|
|
179
|
+
|
|
180
|
+
[Back to top](#table-of-contents)
|
|
181
|
+
|
|
182
|
+
## HMAC Signing
|
|
183
|
+
|
|
184
|
+
Every outgoing request includes an `X-Webhook-Signature` header (configurable) containing an HMAC digest of the request body:
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
X-Webhook-Signature: sha256=a1b2c3d4...
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Subscribers can verify the signature:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
expected = RailsWebhookOutbox::Signature.header_value(raw_body, subscription.secret)
|
|
194
|
+
Rack::Utils.secure_compare(expected, request.headers["X-Webhook-Signature"])
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
You can also call the primitives directly:
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
# Produce a hex digest with an explicit algorithm
|
|
201
|
+
RailsWebhookOutbox::Signature.sign(payload, secret, :sha256)
|
|
202
|
+
# => "a1b2c3d4..."
|
|
203
|
+
|
|
204
|
+
# Produce the full header value using the configured algorithm
|
|
205
|
+
RailsWebhookOutbox::Signature.header_value(payload, secret)
|
|
206
|
+
# => "sha256=a1b2c3d4..."
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
[Back to top](#table-of-contents)
|
|
210
|
+
|
|
211
|
+
## Usage
|
|
212
|
+
|
|
213
|
+
Include `RailsWebhookOutbox::Dispatchable` in any ActiveRecord model to automatically dispatch webhooks on lifecycle events:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
class Order < ApplicationRecord
|
|
217
|
+
include RailsWebhookOutbox::Dispatchable
|
|
218
|
+
|
|
219
|
+
dispatches_webhook "order.created", on: :create
|
|
220
|
+
dispatches_webhook "order.updated", on: :update
|
|
221
|
+
dispatches_webhook "order.cancelled", on: :update,
|
|
222
|
+
if: -> { cancelled_at_previously_changed? }
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
When a callback fires, the concern finds every active `Subscription` that includes that event, creates a `Delivery` record for each one, and enqueues a `DeliveryJob`. No other wiring is required.
|
|
227
|
+
|
|
228
|
+
The `if:` option accepts a lambda that is evaluated in the context of the model instance, so any attribute or method is available.
|
|
229
|
+
|
|
230
|
+
**Payload**
|
|
231
|
+
|
|
232
|
+
By default the full record is sent as the webhook payload via `as_json`. Override `webhook_payload` to control exactly what is sent:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
class Order < ApplicationRecord
|
|
236
|
+
include RailsWebhookOutbox::Dispatchable
|
|
237
|
+
|
|
238
|
+
dispatches_webhook "order.created", on: :create
|
|
239
|
+
|
|
240
|
+
def webhook_payload
|
|
241
|
+
{ id:, total: total.to_s, items: line_items.count }
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
[Back to top](#table-of-contents)
|
|
247
|
+
|
|
248
|
+
## Manual Dispatch
|
|
249
|
+
|
|
250
|
+
Dispatch a webhook event outside of model callbacks using `RailsWebhookOutbox.dispatch`:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
RailsWebhookOutbox.dispatch("payment.completed", {
|
|
254
|
+
id: payment.id,
|
|
255
|
+
amount: payment.amount,
|
|
256
|
+
currency: payment.currency
|
|
257
|
+
})
|
|
258
|
+
```
|
|
259
|
+
|
|
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.
|
|
261
|
+
|
|
262
|
+
This is the same delivery pipeline used by `Dispatchable` callbacks, so retries, HMAC signing, and delivery logging all apply.
|
|
263
|
+
|
|
264
|
+
[Back to top](#table-of-contents)
|
|
265
|
+
|
|
266
|
+
## Development
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
$ bundle install
|
|
270
|
+
$ bundle exec rspec
|
|
271
|
+
$ bin/rubocop
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### Dummy App
|
|
275
|
+
|
|
276
|
+
The gem includes a full dummy Rails app at `spec/dummy/` for end-to-end testing in a running server. It has an `Order` model wired to `Dispatchable`, an `OrdersController`, and seed data.
|
|
277
|
+
|
|
278
|
+
**Setup**
|
|
279
|
+
|
|
280
|
+
From the repo root:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
$ cd spec/dummy
|
|
284
|
+
$ bin/setup # installs deps, runs db:prepare, then starts the server
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Or set up without starting the server:
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
$ bin/rails db:create db:migrate
|
|
291
|
+
$ bin/rails db:schema:load:queue
|
|
292
|
+
$ bin/rails db:seed
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**Start the server**
|
|
296
|
+
|
|
297
|
+
`bin/dev` runs the web server and solid_queue worker together via foreman:
|
|
298
|
+
|
|
299
|
+
```bash
|
|
300
|
+
$ bin/dev
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The API is available at `http://localhost:3000`.
|
|
304
|
+
|
|
305
|
+
**Try it with curl**
|
|
306
|
+
|
|
307
|
+
Create an order (fires `order.created`):
|
|
308
|
+
|
|
309
|
+
```bash
|
|
310
|
+
curl -X POST http://localhost:3000/orders \
|
|
311
|
+
-H "Content-Type: application/json" \
|
|
312
|
+
-d '{"order": {"title": "Widget Pack", "total": "49.99", "status": "pending"}}'
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Update an order (fires `order.updated`):
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
curl -X PATCH http://localhost:3000/orders/1 \
|
|
319
|
+
-H "Content-Type: application/json" \
|
|
320
|
+
-d '{"order": {"status": "confirmed"}}'
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
Cancel an order (fires `order.cancelled`):
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
curl -X PATCH http://localhost:3000/orders/1 \
|
|
327
|
+
-H "Content-Type: application/json" \
|
|
328
|
+
-d '{"order": {"status": "cancelled", "cancelled_at": "2026-06-29T12:00:00Z"}}'
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
The seed data creates a subscription pointing to `http://localhost:4000/webhooks`. Point it at any local receiver (e.g. a `webhook.site` URL or a local listener) by updating the subscription record directly:
|
|
332
|
+
|
|
333
|
+
```ruby
|
|
334
|
+
RailsWebhookOutbox::Subscription.first.update!(url: "https://webhook.site/your-id")
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
[Back to top](#table-of-contents)
|
|
338
|
+
|
|
339
|
+
## Contributing
|
|
340
|
+
|
|
341
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/eclectic-coding/rails_webhook_outbox).
|
|
342
|
+
|
|
343
|
+
[Back to top](#table-of-contents)
|
|
344
|
+
|
|
345
|
+
## License
|
|
346
|
+
|
|
347
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
348
|
+
|
|
349
|
+
[Back to top](#table-of-contents)
|
data/Rakefile
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "bundler/setup"
|
|
2
|
+
|
|
3
|
+
APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
|
|
4
|
+
load "rails/tasks/engine.rake"
|
|
5
|
+
|
|
6
|
+
require "bundler/gem_tasks"
|
|
7
|
+
|
|
8
|
+
require 'rubocop/rake_task'
|
|
9
|
+
require 'bundler/audit/task'
|
|
10
|
+
require 'rspec/core/rake_task'
|
|
11
|
+
|
|
12
|
+
RuboCop::RakeTask.new(:lint)
|
|
13
|
+
Bundler::Audit::Task.new
|
|
14
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
15
|
+
|
|
16
|
+
task default: [:lint, :'bundle:audit:update', 'bundle:audit:check', :spec]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
class DeliveryJob < ApplicationJob
|
|
3
|
+
queue_as { RailsWebhookOutbox.config.delivery_job_queue }
|
|
4
|
+
retry_on RailsWebhookOutbox::DeliveryError, wait: :polynomially_longer, attempts: :unlimited
|
|
5
|
+
|
|
6
|
+
def perform(delivery)
|
|
7
|
+
response = Sender.call(delivery)
|
|
8
|
+
delivery.update!(
|
|
9
|
+
status: :delivered,
|
|
10
|
+
response_code: response.code.to_i,
|
|
11
|
+
response_body: response.body.truncate(1000),
|
|
12
|
+
delivered_at: Time.current,
|
|
13
|
+
attempts: delivery.attempts + 1
|
|
14
|
+
)
|
|
15
|
+
rescue DeliveryError => e
|
|
16
|
+
max_retries = RailsWebhookOutbox.config.max_retries
|
|
17
|
+
delivery.update!(
|
|
18
|
+
response_code: e.response_code,
|
|
19
|
+
response_body: e.response_body&.truncate(1000),
|
|
20
|
+
attempts: delivery.attempts + 1,
|
|
21
|
+
status: executions >= max_retries ? :failed : :pending
|
|
22
|
+
)
|
|
23
|
+
raise if executions < max_retries
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
class Delivery < ApplicationRecord
|
|
3
|
+
self.table_name = "webhook_outbox_deliveries"
|
|
4
|
+
|
|
5
|
+
belongs_to :subscription
|
|
6
|
+
|
|
7
|
+
enum :status, { pending: 0, delivered: 1, failed: 2 }
|
|
8
|
+
|
|
9
|
+
validates :event, presence: true
|
|
10
|
+
validates :payload, presence: true
|
|
11
|
+
|
|
12
|
+
scope :retryable, -> { pending }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
class Subscription < ApplicationRecord
|
|
3
|
+
self.table_name = "webhook_outbox_subscriptions"
|
|
4
|
+
|
|
5
|
+
URL_FORMAT = /\Ahttps?:\/\/.+/i
|
|
6
|
+
|
|
7
|
+
attribute :active, :boolean, default: true
|
|
8
|
+
|
|
9
|
+
before_validation :generate_secret, on: :create
|
|
10
|
+
|
|
11
|
+
validates :url, presence: true, format: { with: URL_FORMAT, message: "must be a valid HTTP or HTTPS URL" }
|
|
12
|
+
validates :secret, presence: true
|
|
13
|
+
validates :events, presence: true
|
|
14
|
+
|
|
15
|
+
scope :active, -> { where(active: true) }
|
|
16
|
+
|
|
17
|
+
def subscribes_to?(event)
|
|
18
|
+
events.include?(event.to_s)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def generate_secret
|
|
24
|
+
self.secret ||= SecureRandom.hex(32)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateWebhookOutboxSubscriptions < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
create_table :webhook_outbox_subscriptions do |t|
|
|
4
|
+
t.string :url, null: false
|
|
5
|
+
t.string :secret, null: false
|
|
6
|
+
t.json :events, null: false, default: []
|
|
7
|
+
t.boolean :active, null: false, default: true
|
|
8
|
+
t.string :description
|
|
9
|
+
t.json :metadata, default: {}
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
create_table :webhook_outbox_deliveries do |t|
|
|
4
|
+
t.references :subscription, null: false, foreign_key: { to_table: :webhook_outbox_subscriptions }
|
|
5
|
+
t.string :event, null: false
|
|
6
|
+
t.json :payload, null: false
|
|
7
|
+
t.integer :status, null: false, default: 0
|
|
8
|
+
t.integer :response_code
|
|
9
|
+
t.text :response_body
|
|
10
|
+
t.integer :attempts, null: false, default: 0
|
|
11
|
+
t.datetime :delivered_at
|
|
12
|
+
t.datetime :next_retry_at
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
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]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module RailsWebhookOutbox
|
|
5
|
+
module Generators
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
include Rails::Generators::Migration
|
|
8
|
+
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
desc "Creates a RailsWebhookOutbox initializer and copies migrations to your application."
|
|
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",
|
|
20
|
+
"db/migrate/create_webhook_outbox_subscriptions.rb"
|
|
21
|
+
)
|
|
22
|
+
migration_template(
|
|
23
|
+
"create_webhook_outbox_deliveries.rb",
|
|
24
|
+
"db/migrate/create_webhook_outbox_deliveries.rb"
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_initializer
|
|
29
|
+
template "initializer.rb", "config/initializers/rails_webhook_outbox.rb"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_deliveries.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
class CreateWebhookOutboxDeliveries < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :webhook_outbox_deliveries do |t|
|
|
4
|
+
t.references :subscription, null: false, foreign_key: { to_table: :webhook_outbox_subscriptions }
|
|
5
|
+
t.string :event, null: false
|
|
6
|
+
t.json :payload, null: false
|
|
7
|
+
t.integer :status, null: false, default: 0
|
|
8
|
+
t.integer :response_code
|
|
9
|
+
t.text :response_body
|
|
10
|
+
t.integer :attempts, null: false, default: 0
|
|
11
|
+
t.datetime :delivered_at
|
|
12
|
+
t.datetime :next_retry_at
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
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]
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/generators/rails_webhook_outbox/install/templates/create_webhook_outbox_subscriptions.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
class CreateWebhookOutboxSubscriptions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :webhook_outbox_subscriptions do |t|
|
|
4
|
+
t.string :url, null: false
|
|
5
|
+
t.string :secret, null: false
|
|
6
|
+
t.json :events, null: false, default: []
|
|
7
|
+
t.boolean :active, null: false, default: true
|
|
8
|
+
t.string :description
|
|
9
|
+
t.json :metadata, default: {}
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
RailsWebhookOutbox.configure do |config|
|
|
2
|
+
# Events that subscribers can register for.
|
|
3
|
+
config.events = %w[order.created order.updated]
|
|
4
|
+
|
|
5
|
+
# HMAC algorithm for signing payloads. Options: :sha256, :sha384, :sha512
|
|
6
|
+
config.signing_algorithm = :sha256
|
|
7
|
+
|
|
8
|
+
# HTTP header that carries the HMAC signature.
|
|
9
|
+
config.signing_header = "X-Webhook-Signature"
|
|
10
|
+
|
|
11
|
+
# Maximum delivery attempts before a delivery is marked failed.
|
|
12
|
+
config.max_retries = 8
|
|
13
|
+
|
|
14
|
+
# Retry delay strategy. Options: :exponential, :linear
|
|
15
|
+
config.retry_backoff = :exponential
|
|
16
|
+
|
|
17
|
+
# HTTP timeout in seconds for each delivery attempt.
|
|
18
|
+
config.request_timeout = 5
|
|
19
|
+
|
|
20
|
+
# ActiveJob queue for delivery jobs.
|
|
21
|
+
config.delivery_job_queue = :webhooks
|
|
22
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
class Configuration
|
|
3
|
+
SIGNING_ALGORITHMS = %i[sha256 sha384 sha512].freeze
|
|
4
|
+
RETRY_BACKOFF_STRATEGIES = %i[exponential linear].freeze
|
|
5
|
+
|
|
6
|
+
attr_accessor :events, :signing_algorithm, :signing_header,
|
|
7
|
+
:max_retries, :retry_backoff, :request_timeout,
|
|
8
|
+
:delivery_job_queue
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@events = []
|
|
12
|
+
@signing_algorithm = :sha256
|
|
13
|
+
@signing_header = "X-Webhook-Signature"
|
|
14
|
+
@max_retries = 8
|
|
15
|
+
@retry_backoff = :exponential
|
|
16
|
+
@request_timeout = 5
|
|
17
|
+
@delivery_job_queue = :webhooks
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def signing_algorithm=(value)
|
|
21
|
+
value = value.to_sym
|
|
22
|
+
unless SIGNING_ALGORITHMS.include?(value)
|
|
23
|
+
raise ArgumentError, "Unknown signing algorithm: #{value}. Must be one of: #{SIGNING_ALGORITHMS.join(", ")}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@signing_algorithm = value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def retry_backoff=(value)
|
|
30
|
+
value = value.to_sym
|
|
31
|
+
unless RETRY_BACKOFF_STRATEGIES.include?(value)
|
|
32
|
+
raise ArgumentError, "Unknown retry backoff strategy: #{value}. Must be one of: #{RETRY_BACKOFF_STRATEGIES.join(", ")}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@retry_backoff = value
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
class DeliveryError < StandardError
|
|
3
|
+
attr_reader :response_code, :response_body
|
|
4
|
+
|
|
5
|
+
def initialize(response)
|
|
6
|
+
@response_code = response.code.to_i
|
|
7
|
+
@response_body = response.body
|
|
8
|
+
super("HTTP #{@response_code}")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
module Dispatchable
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
included do
|
|
6
|
+
class_attribute :_webhook_dispatches, instance_writer: false
|
|
7
|
+
self._webhook_dispatches = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class_methods do
|
|
11
|
+
def dispatches_webhook(event, on:, **options)
|
|
12
|
+
condition = options[:if]
|
|
13
|
+
self._webhook_dispatches = _webhook_dispatches + [{ event: event, on: on, condition: condition }]
|
|
14
|
+
send(:"after_#{on}", -> { _dispatch_webhook(event, condition) })
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def webhook_payload
|
|
19
|
+
as_json
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def _dispatch_webhook(event, condition)
|
|
25
|
+
return if condition && !instance_exec(&condition)
|
|
26
|
+
|
|
27
|
+
payload = webhook_payload
|
|
28
|
+
|
|
29
|
+
Subscription.active.each do |subscription|
|
|
30
|
+
next unless subscription.subscribes_to?(event)
|
|
31
|
+
|
|
32
|
+
delivery = Delivery.create!(
|
|
33
|
+
subscription: subscription,
|
|
34
|
+
event: event,
|
|
35
|
+
payload: payload
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
DeliveryJob.perform_later(delivery)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module RailsWebhookOutbox
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace RailsWebhookOutbox
|
|
4
|
+
config.generators.api_only = true
|
|
5
|
+
|
|
6
|
+
initializer :append_migrations do |app|
|
|
7
|
+
unless app.root.to_s.match?(__dir__)
|
|
8
|
+
RailsWebhookOutbox::Engine.config.paths["db/migrate"].expanded.each do |path|
|
|
9
|
+
app.config.paths["db/migrate"] << path
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module RailsWebhookOutbox
|
|
7
|
+
class Sender
|
|
8
|
+
def self.call(delivery)
|
|
9
|
+
new(delivery).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(delivery)
|
|
13
|
+
@delivery = delivery
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
uri = URI.parse(@delivery.subscription.url)
|
|
18
|
+
body = build_body
|
|
19
|
+
request = build_request(uri, body)
|
|
20
|
+
response = execute(uri, request)
|
|
21
|
+
raise DeliveryError.new(response) unless response.is_a?(Net::HTTPSuccess)
|
|
22
|
+
response
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def build_body
|
|
28
|
+
JSON.generate(
|
|
29
|
+
event: @delivery.event,
|
|
30
|
+
delivered_at: Time.now.utc.iso8601,
|
|
31
|
+
data: @delivery.payload
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_request(uri, body)
|
|
36
|
+
req = Net::HTTP::Post.new(uri)
|
|
37
|
+
req["Content-Type"] = "application/json"
|
|
38
|
+
req["X-Webhook-Signature"] = Signature.header_value(body, @delivery.subscription.secret)
|
|
39
|
+
req["X-Webhook-Event"] = @delivery.event
|
|
40
|
+
req["X-Webhook-Delivery"] = SecureRandom.uuid
|
|
41
|
+
req["X-Webhook-Timestamp"] = Time.now.utc.to_i.to_s
|
|
42
|
+
req.body = body
|
|
43
|
+
req
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def execute(uri, request)
|
|
47
|
+
timeout = RailsWebhookOutbox.config.request_timeout
|
|
48
|
+
Net::HTTP.start(
|
|
49
|
+
uri.host, uri.port,
|
|
50
|
+
use_ssl: uri.scheme == "https",
|
|
51
|
+
open_timeout: timeout,
|
|
52
|
+
read_timeout: timeout
|
|
53
|
+
) do |http|
|
|
54
|
+
http.request(request)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module RailsWebhookOutbox
|
|
4
|
+
module Signature
|
|
5
|
+
def self.sign(payload, secret, algorithm)
|
|
6
|
+
OpenSSL::HMAC.hexdigest(algorithm.to_s.upcase, secret, payload)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.header_value(payload, secret)
|
|
10
|
+
algorithm = RailsWebhookOutbox.config.signing_algorithm
|
|
11
|
+
"#{algorithm}=#{sign(payload, secret, algorithm)}"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "rails_webhook_outbox/version"
|
|
2
|
+
require "rails_webhook_outbox/configuration"
|
|
3
|
+
require "rails_webhook_outbox/signature"
|
|
4
|
+
require "rails_webhook_outbox/delivery_error"
|
|
5
|
+
require "rails_webhook_outbox/sender"
|
|
6
|
+
require "rails_webhook_outbox/dispatchable"
|
|
7
|
+
require "rails_webhook_outbox/engine"
|
|
8
|
+
|
|
9
|
+
module RailsWebhookOutbox
|
|
10
|
+
class << self
|
|
11
|
+
def configuration
|
|
12
|
+
@configuration ||= Configuration.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
alias_method :config, :configuration
|
|
16
|
+
|
|
17
|
+
def configure
|
|
18
|
+
yield(configuration)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def reset_configuration!
|
|
22
|
+
@configuration = Configuration.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dispatch(event, payload)
|
|
26
|
+
Subscription.active.each do |subscription|
|
|
27
|
+
next unless subscription.subscribes_to?(event)
|
|
28
|
+
|
|
29
|
+
delivery = Delivery.create!(
|
|
30
|
+
subscription: subscription,
|
|
31
|
+
event: event,
|
|
32
|
+
payload: payload
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
DeliveryJob.perform_later(delivery)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rails_webhook_outbox
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Chuck Smith
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: rails
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.2'
|
|
26
|
+
description: A Rails engine for sending outgoing webhooks with HMAC signing, ActiveJob-based
|
|
27
|
+
retry, and delivery logging.
|
|
28
|
+
email:
|
|
29
|
+
- eclectic-coding@users.noreply.github.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- MIT-LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- Rakefile
|
|
37
|
+
- app/controllers/rails_webhook_outbox/application_controller.rb
|
|
38
|
+
- app/jobs/rails_webhook_outbox/application_job.rb
|
|
39
|
+
- app/jobs/rails_webhook_outbox/delivery_job.rb
|
|
40
|
+
- app/mailers/rails_webhook_outbox/application_mailer.rb
|
|
41
|
+
- app/models/rails_webhook_outbox/application_record.rb
|
|
42
|
+
- app/models/rails_webhook_outbox/delivery.rb
|
|
43
|
+
- app/models/rails_webhook_outbox/subscription.rb
|
|
44
|
+
- config/routes.rb
|
|
45
|
+
- db/migrate/20260624000001_create_webhook_outbox_subscriptions.rb
|
|
46
|
+
- db/migrate/20260624000002_create_webhook_outbox_deliveries.rb
|
|
47
|
+
- 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
|
|
50
|
+
- lib/generators/rails_webhook_outbox/install/templates/initializer.rb
|
|
51
|
+
- lib/rails_webhook_outbox.rb
|
|
52
|
+
- lib/rails_webhook_outbox/configuration.rb
|
|
53
|
+
- lib/rails_webhook_outbox/delivery_error.rb
|
|
54
|
+
- lib/rails_webhook_outbox/dispatchable.rb
|
|
55
|
+
- lib/rails_webhook_outbox/engine.rb
|
|
56
|
+
- lib/rails_webhook_outbox/sender.rb
|
|
57
|
+
- lib/rails_webhook_outbox/signature.rb
|
|
58
|
+
- lib/rails_webhook_outbox/version.rb
|
|
59
|
+
- lib/tasks/rails_webhook_outbox_tasks.rake
|
|
60
|
+
homepage: https://github.com/eclectic-coding/rails_webhook_outbox
|
|
61
|
+
licenses:
|
|
62
|
+
- MIT
|
|
63
|
+
metadata:
|
|
64
|
+
allowed_push_host: https://rubygems.org
|
|
65
|
+
homepage_uri: https://github.com/eclectic-coding/rails_webhook_outbox
|
|
66
|
+
source_code_uri: https://github.com/eclectic-coding/rails_webhook_outbox
|
|
67
|
+
changelog_uri: https://github.com/eclectic-coding/rails_webhook_outbox/blob/main/CHANGELOG.md
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 3.6.9
|
|
83
|
+
specification_version: 4
|
|
84
|
+
summary: Outgoing webhooks for Rails with HMAC signing and ActiveJob retry
|
|
85
|
+
test_files: []
|