paubox_rails 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +11 -3
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +7 -0
- data/README.md +84 -0
- data/api.md +196 -1
- data/lib/paubox_rails/version.rb +1 -1
- data/lib/paubox_rails/webhooks/client.rb +210 -0
- data/lib/paubox_rails/webhooks.rb +28 -0
- data/lib/paubox_rails.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a972155ed90a4c432adfbf7751a10ddcc234be6a581f3ce0ce25ee772676203f
|
|
4
|
+
data.tar.gz: 63a11cb9f8e8fdf508658807638da111f62ae36c41829d9203d9c81ecbf53a3e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7cad38c91127705763494a330c81783ae79a884f46379ee7eec2a6b2682a7f919ca6012c1bf2349731a76e6d7daa693dca33e913c97edc4651af98aa4f7bbd0b
|
|
7
|
+
data.tar.gz: c5c599dc45256ed136dd65eb7e0c006d45cbf76286995eadc388f0e967a0a1adb1d1ce43aa90772e824fb72034ce4ed1fd0815f18f1d6dd46160401af2bf3e42
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -47,7 +47,15 @@ jobs:
|
|
|
47
47
|
steps:
|
|
48
48
|
- uses: actions/checkout@v4
|
|
49
49
|
|
|
50
|
-
- name: Probe the
|
|
50
|
+
- name: Probe the base URLs the clients use
|
|
51
51
|
run: |
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
{
|
|
53
|
+
grep -hoE 'https://api\.paubox\.com[a-zA-Z0-9/_.-]*' lib/paubox_rails/forms.rb
|
|
54
|
+
# The webhooks base is probed with /endpoints appended. The gateway
|
|
55
|
+
# scopes its rule to that one resource so the producers' /v1/events
|
|
56
|
+
# ingest route stays off the public host, which leaves the bare base
|
|
57
|
+
# unrouted by design — probing it directly would fail this check for
|
|
58
|
+
# a URL the client never requests on its own.
|
|
59
|
+
grep -hoE 'https://api\.paubox\.com[a-zA-Z0-9/_.-]*' lib/paubox_rails/webhooks.rb \
|
|
60
|
+
| sed 's#/*$#/endpoints#'
|
|
61
|
+
} | .github/scripts/route-check.sh
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.2.0](https://github.com/Paubox/paubox-rails/compare/v1.1.0...v1.2.0) (2026-10-14)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* add a client for the pb_webhooks service ([#25](https://github.com/Paubox/paubox-rails/issues/25)) ([910dfd0](https://github.com/Paubox/paubox-rails/commit/910dfd0fa3eea024eeabcc23180ad5a95c0b5bc8))
|
|
11
|
+
|
|
5
12
|
## [1.1.0](https://github.com/Paubox/paubox-rails/compare/v1.0.0...v1.1.0) (2026-09-10)
|
|
6
13
|
|
|
7
14
|
|
data/README.md
CHANGED
|
@@ -204,6 +204,90 @@ File.write('submissions.csv', client.submissions_csv(form_id))
|
|
|
204
204
|
|
|
205
205
|
Also available: `get_form_details`, `update_form`, `archive_form`, `unarchive_form`, `copy_form`, `form_stats`, and `submission_pdf`. See [api.md](api.md) for the full Forms API reference.
|
|
206
206
|
|
|
207
|
+
## Webhooks
|
|
208
|
+
|
|
209
|
+
A webhook endpoint is a URL you own that Paubox notifies when something happens. `PauboxRails::Webhooks` manages those subscriptions: which URL to notify, and for which events.
|
|
210
|
+
|
|
211
|
+
Build a client with an API key scoped for the events you want, or rely on the `PAUBOX_WEBHOOKS_API_KEY` environment variable:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
client = PauboxRails::Webhooks.client(api_key: ENV['PAUBOX_WEBHOOKS_API_KEY'])
|
|
215
|
+
|
|
216
|
+
# or, reading the environment variable itself:
|
|
217
|
+
client = PauboxRails::Webhooks.client
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The key is sent as an `Authorization: Bearer` header — not the Email API's `Token token=`. Calling a method without one raises `PauboxRails::Webhooks::MissingApiKeyError` before any HTTP request is made. Unlike the Forms client, the base URL is overridable, via `base_url:` or `PAUBOX_WEBHOOKS_URL`, so staging QA does not require editing the gem.
|
|
221
|
+
|
|
222
|
+
Which events a key may subscribe to follows from its scopes. The SDK does not inspect them — the service refuses an event the key isn't scoped for with 403 (`ForbiddenError`), and an unrecognised event with 422.
|
|
223
|
+
|
|
224
|
+
**Available events**
|
|
225
|
+
|
|
226
|
+
| Event | Fires when |
|
|
227
|
+
| --- | --- |
|
|
228
|
+
| `forms.submission.created` | A form submission is received |
|
|
229
|
+
| `api_mail_log_delivered` | An Email API message is delivered |
|
|
230
|
+
| `api_mail_log_opened` | A delivered message is opened |
|
|
231
|
+
| `api_mail_log_temporary_failure` | Delivery fails temporarily |
|
|
232
|
+
| `api_mail_log_permanent_failure` | Delivery fails permanently |
|
|
233
|
+
| `email.inbound.received` | Inbound mail is received |
|
|
234
|
+
|
|
235
|
+
The four Email API events keep their legacy `api_mail_log_` names, so a receiver written
|
|
236
|
+
against the old Email API webhooks keeps working unchanged. Note these are the names on
|
|
237
|
+
the wire: `delivered` or `failed` on their own are rejected as unknown.
|
|
238
|
+
|
|
239
|
+
The SDK does not check these names. The catalog lives in the service, so an event added
|
|
240
|
+
later works here without upgrading the SDK.
|
|
241
|
+
|
|
242
|
+
### Creating a webhook endpoint
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
endpoint = client.create_webhook_endpoint(
|
|
246
|
+
target_url: 'https://example.com/paubox-webhook',
|
|
247
|
+
events: ['forms.submission.created']
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
endpoint['id'] # UUID
|
|
251
|
+
endpoint['signing_secret'] # whsec_... — store this now
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
`signing_secret` is returned **once**, here. It is absent from `get_webhook_endpoint` and `list_webhook_endpoints`, and there is no way to read it back — recovering from a lost secret means deleting the endpoint and creating a new one. Use it to verify that deliveries really came from Paubox.
|
|
255
|
+
|
|
256
|
+
### Listing, fetching, updating and deleting
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
# Returns the full document: "data" plus "page_info"
|
|
260
|
+
result = client.list_webhook_endpoints
|
|
261
|
+
result['page_info']['count'] # total matching, not the length of this page
|
|
262
|
+
|
|
263
|
+
client.list_webhook_endpoints(page: 2, items: 25)
|
|
264
|
+
|
|
265
|
+
client.get_webhook_endpoint('2ec66c21-bf48-48eb-8d28-f80b2d6b77c7')
|
|
266
|
+
|
|
267
|
+
# Only the keyword arguments you pass are sent
|
|
268
|
+
client.update_webhook_endpoint(endpoint_id, status: 'disabled')
|
|
269
|
+
|
|
270
|
+
client.delete_webhook_endpoint(endpoint_id)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Pausing deliveries without losing the subscription is what `disabled` is for; pass `active` to resume. Passing `events:` replaces the list rather than adding to it. Delete answers `204 No Content`, stops every event on that endpoint, and takes the signing secret with it.
|
|
274
|
+
|
|
275
|
+
### Error handling
|
|
276
|
+
|
|
277
|
+
Failures raise a `PauboxRails::Webhooks::Error` subclass chosen by status — `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError` — with the service's own message rather than a bare status:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
begin
|
|
281
|
+
client.create_webhook_endpoint(target_url: url, events: ['forms.submission.created'])
|
|
282
|
+
rescue PauboxRails::Webhooks::Error => e
|
|
283
|
+
e.message # "target_url: an endpoint already exists for this URL"
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Two cases worth handling explicitly: subscribing a `target_url` that already has an endpoint comes back as 422, and an id that doesn't belong to your account is a 404 rather than a 403. An id that isn't a UUID raises `ArgumentError` locally, before any request is made.
|
|
288
|
+
|
|
289
|
+
See [api.md](api.md) for the full webhooks reference.
|
|
290
|
+
|
|
207
291
|
## Contributing
|
|
208
292
|
|
|
209
293
|
Bug reports and pull requests are welcome on GitHub at https://github.com/paubox/paubox-rails.
|
data/api.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# Paubox API Reference
|
|
2
2
|
|
|
3
|
-
This document covers the API endpoints exposed by `paubox-rails`. The gem wraps
|
|
3
|
+
This document covers the API endpoints exposed by `paubox-rails`. The gem wraps three separate Paubox services:
|
|
4
4
|
|
|
5
5
|
- **Email API** — authenticated, delegates to the [`paubox`](https://github.com/paubox/paubox_ruby) gem via ActionMailer
|
|
6
6
|
- **Forms API** — public respondent endpoints plus authenticated management endpoints, implemented directly in this gem
|
|
7
|
+
- **Webhooks API** — authenticated subscription management, implemented directly in this gem
|
|
7
8
|
|
|
8
9
|
---
|
|
9
10
|
|
|
@@ -689,3 +690,197 @@ File.binwrite('submission.pdf', pdf)
|
|
|
689
690
|
Email delivery is handled transparently by ActionMailer using the `:paubox` delivery method. There is no direct HTTP interaction in this gem — all email API calls are delegated to the [`paubox`](https://github.com/paubox/paubox_ruby) gem.
|
|
690
691
|
|
|
691
692
|
See the [Paubox Ruby gem documentation](https://github.com/paubox/paubox_ruby) for the full Email API reference, including message status tracking and templated messages.
|
|
693
|
+
|
|
694
|
+
---
|
|
695
|
+
|
|
696
|
+
## Webhooks API
|
|
697
|
+
|
|
698
|
+
**Base URL:** `https://api.paubox.com/v1/webhooks`
|
|
699
|
+
|
|
700
|
+
Every request carries the `/endpoints` resource. The bare base is unrouted on the public gateway by design: the rule is scoped to `/endpoints` so the producers' event-ingest route stays private. Unlike Forms, the base is overridable with `base_url:` or `PAUBOX_WEBHOOKS_URL`.
|
|
701
|
+
|
|
702
|
+
### Authentication
|
|
703
|
+
|
|
704
|
+
An API key scoped for the events you want, sent as a Bearer header:
|
|
705
|
+
|
|
706
|
+
```
|
|
707
|
+
Authorization: Bearer <SCOPED_API_KEY>
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
Read from `PAUBOX_WEBHOOKS_API_KEY` when not passed explicitly. Calling a method without a key raises `MissingApiKeyError` before any request is sent. Which events a key may subscribe to follows from its scopes; the gem does not inspect them, so an event the key is not scoped for returns 403 and an unrecognised one 422.
|
|
711
|
+
|
|
712
|
+
**Available events**
|
|
713
|
+
|
|
714
|
+
| Event | Fires when |
|
|
715
|
+
| --- | --- |
|
|
716
|
+
| `forms.submission.created` | A form submission is received |
|
|
717
|
+
| `api_mail_log_delivered` | An Email API message is delivered |
|
|
718
|
+
| `api_mail_log_opened` | A delivered message is opened |
|
|
719
|
+
| `api_mail_log_temporary_failure` | Delivery fails temporarily |
|
|
720
|
+
| `api_mail_log_permanent_failure` | Delivery fails permanently |
|
|
721
|
+
| `email.inbound.received` | Inbound mail is received |
|
|
722
|
+
|
|
723
|
+
The four Email API events keep their legacy `api_mail_log_` names, so a receiver written
|
|
724
|
+
against the old Email API webhooks keeps working unchanged. Note these are the names on
|
|
725
|
+
the wire: `delivered` or `failed` on their own are rejected as unknown.
|
|
726
|
+
|
|
727
|
+
The SDK does not check these names. The catalog lives in the service, so an event added
|
|
728
|
+
later works here without upgrading the SDK.
|
|
729
|
+
|
|
730
|
+
```ruby
|
|
731
|
+
client = PauboxRails::Webhooks.client(api_key: ENV['PAUBOX_WEBHOOKS_API_KEY'])
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
---
|
|
735
|
+
|
|
736
|
+
### List Webhook Endpoints
|
|
737
|
+
|
|
738
|
+
**GET** `/endpoints`
|
|
739
|
+
|
|
740
|
+
Returns the endpoints this key can act on. Endpoints carrying an event the key is not scoped for are filtered out by the service.
|
|
741
|
+
|
|
742
|
+
**Query parameters:**
|
|
743
|
+
- `page` (integer, optional) — 1-based page number
|
|
744
|
+
- `items` (integer, optional) — page size
|
|
745
|
+
|
|
746
|
+
**Response (200):**
|
|
747
|
+
```json
|
|
748
|
+
{
|
|
749
|
+
"data": [
|
|
750
|
+
{
|
|
751
|
+
"id": "2ec66c21-bf48-48eb-8d28-f80b2d6b77c7",
|
|
752
|
+
"target_url": "https://example.com/paubox-webhook",
|
|
753
|
+
"status": "active",
|
|
754
|
+
"events": ["forms.submission.created"],
|
|
755
|
+
"created_at": "2026-10-09T01:43:41.639968+00:00",
|
|
756
|
+
"updated_at": "2026-10-09T01:43:41.639968+00:00"
|
|
757
|
+
}
|
|
758
|
+
],
|
|
759
|
+
"page_info": { "count": 1, "items": 50 }
|
|
760
|
+
}
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
`page_info.count` is the total number of matching endpoints, not the length of `data`.
|
|
764
|
+
|
|
765
|
+
```ruby
|
|
766
|
+
result = client.list_webhook_endpoints
|
|
767
|
+
result = client.list_webhook_endpoints(page: 2, items: 25)
|
|
768
|
+
```
|
|
769
|
+
|
|
770
|
+
Returns the full document — `data` plus `page_info` — since a caller paginating needs both.
|
|
771
|
+
|
|
772
|
+
---
|
|
773
|
+
|
|
774
|
+
### Create Webhook Endpoint
|
|
775
|
+
|
|
776
|
+
**POST** `/endpoints`
|
|
777
|
+
|
|
778
|
+
**Request body:**
|
|
779
|
+
- `target_url` (string, required) — an https URL resolving to a publicly routable address
|
|
780
|
+
- `events` (array of strings, required, non-empty)
|
|
781
|
+
|
|
782
|
+
**Response (201):**
|
|
783
|
+
```json
|
|
784
|
+
{
|
|
785
|
+
"data": {
|
|
786
|
+
"id": "2ec66c21-bf48-48eb-8d28-f80b2d6b77c7",
|
|
787
|
+
"target_url": "https://example.com/paubox-webhook",
|
|
788
|
+
"status": "active",
|
|
789
|
+
"events": ["forms.submission.created"],
|
|
790
|
+
"created_at": "2026-10-09T01:43:41.639968+00:00",
|
|
791
|
+
"updated_at": "2026-10-09T01:43:41.639968+00:00",
|
|
792
|
+
"signing_secret": "whsec_..."
|
|
793
|
+
},
|
|
794
|
+
"message": "Store this signing_secret now — it is not shown again."
|
|
795
|
+
}
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
`signing_secret` is returned only here. It is absent from get and list, and cannot be read back: recovering from a lost secret means replacing the endpoint.
|
|
799
|
+
|
|
800
|
+
**Errors:**
|
|
801
|
+
- `422` (`BadRequestError`) — missing or malformed `target_url`, an empty `events`, an unrecognised event, or a `target_url` that already has an endpoint
|
|
802
|
+
- `403` (`ForbiddenError`) — an event this key is not scoped for
|
|
803
|
+
|
|
804
|
+
```ruby
|
|
805
|
+
endpoint = client.create_webhook_endpoint(
|
|
806
|
+
target_url: 'https://example.com/paubox-webhook',
|
|
807
|
+
events: ['forms.submission.created']
|
|
808
|
+
)
|
|
809
|
+
endpoint['signing_secret']
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
Returns the endpoint itself, unwrapped from the `data` envelope.
|
|
813
|
+
|
|
814
|
+
---
|
|
815
|
+
|
|
816
|
+
### Get Webhook Endpoint
|
|
817
|
+
|
|
818
|
+
**GET** `/endpoints/<id>`
|
|
819
|
+
|
|
820
|
+
**Path parameter:**
|
|
821
|
+
- `endpoint_id` (UUID, required)
|
|
822
|
+
|
|
823
|
+
**Response (200):** the endpoint, wrapped in `data`. The signing secret is not included.
|
|
824
|
+
|
|
825
|
+
**Errors:**
|
|
826
|
+
- `404` (`NotFoundError`) — no such endpoint, or it belongs to another account
|
|
827
|
+
|
|
828
|
+
```ruby
|
|
829
|
+
client.get_webhook_endpoint('2ec66c21-bf48-48eb-8d28-f80b2d6b77c7')
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
An id that is not UUID-shaped raises `ArgumentError` before any request is sent.
|
|
833
|
+
|
|
834
|
+
---
|
|
835
|
+
|
|
836
|
+
### Update Webhook Endpoint
|
|
837
|
+
|
|
838
|
+
**PATCH** `/endpoints/<id>`
|
|
839
|
+
|
|
840
|
+
A partial update: only the fields sent are changed.
|
|
841
|
+
|
|
842
|
+
**Request body (all optional, at least one required):**
|
|
843
|
+
- `target_url` (string)
|
|
844
|
+
- `status` (string) — `"active"` or `"disabled"`
|
|
845
|
+
- `events` (array of strings) — replaces the list rather than adding to it
|
|
846
|
+
|
|
847
|
+
**Response (200):** the updated endpoint, wrapped in `data`.
|
|
848
|
+
|
|
849
|
+
```ruby
|
|
850
|
+
client.update_webhook_endpoint(endpoint_id, status: 'disabled')
|
|
851
|
+
client.update_webhook_endpoint(endpoint_id, target_url: 'https://example.com/new-hook')
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
`disabled` pauses deliveries without losing the subscription; `active` resumes them.
|
|
855
|
+
|
|
856
|
+
---
|
|
857
|
+
|
|
858
|
+
### Delete Webhook Endpoint
|
|
859
|
+
|
|
860
|
+
**DELETE** `/endpoints/<id>`
|
|
861
|
+
|
|
862
|
+
**Response:**
|
|
863
|
+
- `204` — deleted, no body
|
|
864
|
+
- `404` (`NotFoundError`) — no such endpoint, or it belongs to another account
|
|
865
|
+
|
|
866
|
+
Deleting stops every event on that endpoint and discards its signing secret.
|
|
867
|
+
|
|
868
|
+
```ruby
|
|
869
|
+
client.delete_webhook_endpoint(endpoint_id) # => true
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
---
|
|
873
|
+
|
|
874
|
+
### Webhook Errors
|
|
875
|
+
|
|
876
|
+
Failures raise a subclass of `PauboxRails::Webhooks::Error`, chosen by status:
|
|
877
|
+
|
|
878
|
+
| Status | Class |
|
|
879
|
+
|---|---|
|
|
880
|
+
| 400, 422 | `BadRequestError` |
|
|
881
|
+
| 401 | `UnauthorizedError` |
|
|
882
|
+
| 403 | `ForbiddenError` |
|
|
883
|
+
| 404 | `NotFoundError` |
|
|
884
|
+
| no API key | `MissingApiKeyError` |
|
|
885
|
+
|
|
886
|
+
The message is unwrapped from the service's `{"message": "..."}` shape rather than interpolating the raw body, so a caller sees `target_url: must be an https URL` instead of a JSON blob. An unexpected shape falls back to the raw body so nothing is lost.
|
data/lib/paubox_rails/version.rb
CHANGED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
require 'net/http'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module PauboxRails
|
|
6
|
+
module Webhooks
|
|
7
|
+
# Client for the Paubox webhooks service.
|
|
8
|
+
#
|
|
9
|
+
# Authenticates with a scoped API key sent as "Authorization: Bearer
|
|
10
|
+
# <key>" — the same scheme as Forms, and a different one from the Email
|
|
11
|
+
# API's "Token token=". Which events a key may subscribe to is decided by
|
|
12
|
+
# its scopes; asking for one it is not scoped for is refused with 403.
|
|
13
|
+
class Client
|
|
14
|
+
UUID_PATTERN = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/.freeze
|
|
15
|
+
|
|
16
|
+
# Net::HTTP ships with no default socket timeouts, so a black-holed
|
|
17
|
+
# connection hangs for the TCP stack default rather than raising. These
|
|
18
|
+
# are tighter than the Forms client's because nothing here streams a
|
|
19
|
+
# CSV or a PDF — every response is a small JSON document.
|
|
20
|
+
DEFAULT_OPEN_TIMEOUT = 10
|
|
21
|
+
DEFAULT_READ_TIMEOUT = 30
|
|
22
|
+
|
|
23
|
+
attr_reader :api_key, :base_url
|
|
24
|
+
|
|
25
|
+
def initialize(api_key: ENV.fetch(ENV_API_KEY, nil), base_url: nil)
|
|
26
|
+
@api_key = api_key
|
|
27
|
+
@base_url = (base_url || ENV.fetch(ENV_BASE_URL, nil) || BASE_URL).sub(%r{/+\z}, '')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Lists the endpoints this key can act on. Endpoints carrying an event
|
|
31
|
+
# the key is not scoped for are filtered out by the service, so this is
|
|
32
|
+
# what the key may manage rather than everything on the account.
|
|
33
|
+
#
|
|
34
|
+
# Returns the full document: "data" plus "page_info". Note page_info
|
|
35
|
+
# "count" is the total number of matching endpoints, not the length of
|
|
36
|
+
# this page.
|
|
37
|
+
def list_webhook_endpoints(page: nil, items: nil)
|
|
38
|
+
authenticated_get("/endpoints#{build_query(page: page, items: items)}")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Subscribes a URL to one or more events.
|
|
42
|
+
#
|
|
43
|
+
# The response carries "signing_secret", which the service returns only
|
|
44
|
+
# here — it is absent from get_webhook_endpoint and from the list. Store
|
|
45
|
+
# it on receipt; a lost secret means replacing the endpoint.
|
|
46
|
+
#
|
|
47
|
+
# Event names are not validated here on purpose: the catalog belongs to
|
|
48
|
+
# the service and grows without a gem release. An unknown event comes
|
|
49
|
+
# back as 422, one this key is not scoped for as 403.
|
|
50
|
+
def create_webhook_endpoint(target_url:, events:)
|
|
51
|
+
raise ArgumentError, 'target_url is required' if target_url.nil? || target_url.to_s.empty?
|
|
52
|
+
raise ArgumentError, 'events must not be empty' if events.nil? || Array(events).empty?
|
|
53
|
+
|
|
54
|
+
unwrap_data(
|
|
55
|
+
authenticated_post('/endpoints', body: { target_url: target_url, events: Array(events) })
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def get_webhook_endpoint(endpoint_id)
|
|
60
|
+
endpoint_id = path_segment!(endpoint_id, 'endpoint_id')
|
|
61
|
+
unwrap_data(authenticated_get("/endpoints/#{endpoint_id}"))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Partial update — only the arguments given are sent, so changing
|
|
65
|
+
# target_url leaves events and status untouched. status accepts
|
|
66
|
+
# "active" or "disabled".
|
|
67
|
+
def update_webhook_endpoint(endpoint_id, target_url: nil, status: nil, events: nil)
|
|
68
|
+
endpoint_id = path_segment!(endpoint_id, 'endpoint_id')
|
|
69
|
+
|
|
70
|
+
body = {}
|
|
71
|
+
body[:target_url] = target_url unless target_url.nil?
|
|
72
|
+
body[:status] = status unless status.nil?
|
|
73
|
+
body[:events] = Array(events) unless events.nil?
|
|
74
|
+
raise ArgumentError, 'provide at least one of target_url:, status:, events:' if body.empty?
|
|
75
|
+
|
|
76
|
+
unwrap_data(authenticated_patch("/endpoints/#{endpoint_id}", body: body))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Deletes an endpoint, stopping every event on it. The service answers
|
|
80
|
+
# 204 with no body, so this returns true rather than a document.
|
|
81
|
+
def delete_webhook_endpoint(endpoint_id)
|
|
82
|
+
endpoint_id = path_segment!(endpoint_id, 'endpoint_id')
|
|
83
|
+
authenticated_delete("/endpoints/#{endpoint_id}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Single resources come back wrapped: {"data": {...}}. Unwrapped here so
|
|
89
|
+
# callers get the endpoint itself rather than having to reach through an
|
|
90
|
+
# envelope that carries nothing else.
|
|
91
|
+
#
|
|
92
|
+
# list_webhook_endpoints deliberately does not use this — its envelope
|
|
93
|
+
# also carries "page_info", which a caller paginating actually needs.
|
|
94
|
+
def unwrap_data(parsed)
|
|
95
|
+
parsed.is_a?(Hash) && parsed.key?('data') ? parsed['data'] : parsed
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Sanitize a caller-supplied id before interpolating it into a URL path.
|
|
99
|
+
# Without this, a value containing "..", "/", "?" or "#" changes which
|
|
100
|
+
# endpoint is called — and since the Authorization header goes on the
|
|
101
|
+
# same host, the key rides along on the retargeted request. Unlike the
|
|
102
|
+
# Forms client there is no public-endpoint exception here: every id on
|
|
103
|
+
# this service is a UUID, so the strict check applies throughout.
|
|
104
|
+
def path_segment!(value, name)
|
|
105
|
+
raise ArgumentError, "#{name} is required and must not be empty" if value.nil? || value.to_s.empty?
|
|
106
|
+
|
|
107
|
+
s = value.to_s
|
|
108
|
+
raise ArgumentError, "#{name} must be a UUID, got #{s.inspect}" if s !~ UUID_PATTERN
|
|
109
|
+
|
|
110
|
+
# A no-op for a UUID; kept as defense-in-depth so the guarantee holds
|
|
111
|
+
# if the pattern is ever relaxed.
|
|
112
|
+
URI.encode_www_form_component(s).gsub('+', '%20')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def authenticated_get(path)
|
|
116
|
+
require_api_key!
|
|
117
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
118
|
+
request = Net::HTTP::Get.new(uri.request_uri, auth_headers)
|
|
119
|
+
handle_response(build_http(uri).request(request))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def authenticated_post(path, body: nil)
|
|
123
|
+
require_api_key!
|
|
124
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
125
|
+
request = Net::HTTP::Post.new(uri.request_uri, auth_headers('Content-Type' => 'application/json'))
|
|
126
|
+
request.body = body.to_json if body
|
|
127
|
+
handle_response(build_http(uri).request(request))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def authenticated_patch(path, body: nil)
|
|
131
|
+
require_api_key!
|
|
132
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
133
|
+
request = Net::HTTP::Patch.new(uri.request_uri, auth_headers('Content-Type' => 'application/json'))
|
|
134
|
+
request.body = body.to_json if body
|
|
135
|
+
handle_response(build_http(uri).request(request))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def authenticated_delete(path)
|
|
139
|
+
require_api_key!
|
|
140
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
141
|
+
request = Net::HTTP::Delete.new(uri.request_uri, auth_headers)
|
|
142
|
+
handle_response(build_http(uri).request(request))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def auth_headers(extra = {})
|
|
146
|
+
{ 'Authorization' => "Bearer #{@api_key}" }.merge(extra)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def require_api_key!
|
|
150
|
+
return if @api_key
|
|
151
|
+
|
|
152
|
+
raise MissingApiKeyError, 'This endpoint requires an API key. Pass api_key: to ' \
|
|
153
|
+
'PauboxRails::Webhooks.client or set the ' \
|
|
154
|
+
"#{ENV_API_KEY} environment variable."
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def build_query(params)
|
|
158
|
+
filtered = params.reject { |_key, value| value.nil? }
|
|
159
|
+
return '' if filtered.empty?
|
|
160
|
+
|
|
161
|
+
"?#{URI.encode_www_form(filtered)}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def build_http(uri)
|
|
165
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
166
|
+
http.use_ssl = uri.scheme == 'https'
|
|
167
|
+
http.open_timeout = DEFAULT_OPEN_TIMEOUT
|
|
168
|
+
http.read_timeout = DEFAULT_READ_TIMEOUT
|
|
169
|
+
http
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Differs from the Forms client in two ways that matter.
|
|
173
|
+
#
|
|
174
|
+
# 201 returns the parsed body rather than true: create is the only place
|
|
175
|
+
# the signing secret is ever available, and returning true would throw
|
|
176
|
+
# it away irrecoverably.
|
|
177
|
+
#
|
|
178
|
+
# Error messages are unwrapped from the service's {"message": "..."}
|
|
179
|
+
# shape instead of interpolating the raw body, so a caller sees
|
|
180
|
+
# "target_url: must be an https URL" rather than a JSON blob.
|
|
181
|
+
def handle_response(response)
|
|
182
|
+
case response.code.to_i
|
|
183
|
+
when 200, 201
|
|
184
|
+
response.body.to_s.empty? ? true : JSON.parse(response.body)
|
|
185
|
+
when 204
|
|
186
|
+
true
|
|
187
|
+
when 400, 422
|
|
188
|
+
raise BadRequestError, error_message(response)
|
|
189
|
+
when 401
|
|
190
|
+
raise UnauthorizedError, error_message(response)
|
|
191
|
+
when 403
|
|
192
|
+
raise ForbiddenError, error_message(response)
|
|
193
|
+
when 404
|
|
194
|
+
raise NotFoundError, 'Webhook endpoint not found'
|
|
195
|
+
else
|
|
196
|
+
raise Error, "Unexpected response #{response.code}: #{response.body}"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Falls back to the raw body so an unexpected shape still reaches the
|
|
201
|
+
# caller rather than becoming an empty message.
|
|
202
|
+
def error_message(response)
|
|
203
|
+
parsed = JSON.parse(response.body.to_s)
|
|
204
|
+
parsed.is_a?(Hash) && parsed['message'] ? parsed['message'] : response.body.to_s
|
|
205
|
+
rescue JSON::ParserError
|
|
206
|
+
response.body.to_s
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require 'paubox_rails/webhooks/client'
|
|
2
|
+
|
|
3
|
+
module PauboxRails
|
|
4
|
+
module Webhooks
|
|
5
|
+
# The public gateway exposes only /v1/webhooks/endpoints and rewrites it
|
|
6
|
+
# onto the service's own /v1/endpoints, so the producers' event-ingest
|
|
7
|
+
# route is not reachable from here. The bare base is deliberately unrouted;
|
|
8
|
+
# every request this client makes carries the /endpoints resource.
|
|
9
|
+
BASE_URL = 'https://api.paubox.com/v1/webhooks'.freeze
|
|
10
|
+
|
|
11
|
+
# Unlike Forms, the base URL is overridable. Staging QA needs it, and
|
|
12
|
+
# hardcoding it is why pointing the Forms client at a non-production
|
|
13
|
+
# environment means editing the gem.
|
|
14
|
+
ENV_BASE_URL = 'PAUBOX_WEBHOOKS_URL'.freeze
|
|
15
|
+
ENV_API_KEY = 'PAUBOX_WEBHOOKS_API_KEY'.freeze
|
|
16
|
+
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
class NotFoundError < Error; end
|
|
19
|
+
class BadRequestError < Error; end
|
|
20
|
+
class UnauthorizedError < Error; end
|
|
21
|
+
class ForbiddenError < Error; end
|
|
22
|
+
class MissingApiKeyError < Error; end
|
|
23
|
+
|
|
24
|
+
def self.client(**options)
|
|
25
|
+
Client.new(**options)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/paubox_rails.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: paubox_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Paubox, Inc.
|
|
@@ -123,6 +123,8 @@ files:
|
|
|
123
123
|
- lib/paubox_rails/forms/client.rb
|
|
124
124
|
- lib/paubox_rails/railtie.rb
|
|
125
125
|
- lib/paubox_rails/version.rb
|
|
126
|
+
- lib/paubox_rails/webhooks.rb
|
|
127
|
+
- lib/paubox_rails/webhooks/client.rb
|
|
126
128
|
- paubox_rails.gemspec
|
|
127
129
|
- release-please-config.json
|
|
128
130
|
homepage: https://www.paubox.com
|