broadcast-ruby 0.2.0 → 0.3.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/CHANGELOG.md +87 -0
- data/Gemfile.lock +3 -2
- data/README.md +272 -17
- data/SDK-COVERAGE.md +422 -0
- data/lib/broadcast/client.rb +36 -136
- data/lib/broadcast/configuration.rb +53 -4
- data/lib/broadcast/connection.rb +278 -0
- data/lib/broadcast/debug_logger.rb +64 -0
- data/lib/broadcast/delivery_method.rb +5 -0
- data/lib/broadcast/errors.rb +27 -1
- data/lib/broadcast/resources/autopilots.rb +100 -0
- data/lib/broadcast/resources/discovery.rb +36 -0
- data/lib/broadcast/resources/migration.rb +75 -0
- data/lib/broadcast/resources/opt_in_forms.rb +12 -0
- data/lib/broadcast/resources/subscribers.rb +24 -0
- data/lib/broadcast/resources/templates.rb +17 -0
- data/lib/broadcast/resources/transactionals.rb +39 -2
- data/lib/broadcast/response.rb +104 -0
- data/lib/broadcast/version.rb +1 -1
- data/lib/broadcast/webhook.rb +34 -0
- data/lib/broadcast.rb +6 -0
- metadata +9 -3
- data/.rubocop.yml +0 -50
data/SDK-COVERAGE.md
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# broadcast-ruby — coverage
|
|
2
|
+
|
|
3
|
+
What this gem covers, what it deliberately doesn't, and what the Broadcast
|
|
4
|
+
application can do that no client can reach.
|
|
5
|
+
|
|
6
|
+
**Headline: 104/104 API operations (100%).**
|
|
7
|
+
|
|
8
|
+
That number is narrower than it sounds, and [Limits of the 100%](#limits-of-the-100)
|
|
9
|
+
explains exactly what it does not measure. Read that section before quoting the
|
|
10
|
+
figure.
|
|
11
|
+
|
|
12
|
+
- Gem version: **0.3.0**
|
|
13
|
+
- API version: **2.19.0** (75 paths, 104 operations)
|
|
14
|
+
- Generated by: `bin/rails "openapi:coverage[../broadcast-ruby]"` in the
|
|
15
|
+
`broadcast` repo
|
|
16
|
+
- Verified: self-hosted and SaaS mode (the `/api` surface is identical in both)
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Contents
|
|
21
|
+
|
|
22
|
+
1. [How coverage is measured](#how-coverage-is-measured)
|
|
23
|
+
2. [Endpoint map](#endpoint-map) — every operation → the method that calls it
|
|
24
|
+
3. [Transport coverage](#transport-coverage) — the protocol, not the endpoints
|
|
25
|
+
4. [Webhook coverage](#webhook-coverage) — inbound
|
|
26
|
+
5. [Not in the API at all](#not-in-the-api-at-all) — app features no client can reach
|
|
27
|
+
6. [Limits of the 100%](#limits-of-the-100)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## How coverage is measured
|
|
32
|
+
|
|
33
|
+
The `broadcast` repo generates an OpenAPI 3.1 document from `config/routes.rb`
|
|
34
|
+
plus the `*_PARAM_KEYS` constants each controller declares, then scans this
|
|
35
|
+
gem's source for the paths it calls.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
# in the broadcast repo
|
|
39
|
+
bin/rails openapi:generate
|
|
40
|
+
bin/rails "openapi:coverage[../broadcast-ruby]"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Two details worth knowing when reading the map below:
|
|
44
|
+
|
|
45
|
+
- **PATCH is canonical.** Rails' `resources` routes `update` to both PATCH and
|
|
46
|
+
PUT. They are one operation; the generator drops the PUT alias. Counting both
|
|
47
|
+
would inflate the denominator by 8 and make this gem look 8 endpoints short.
|
|
48
|
+
- **Metaprogrammed endpoints are declared.** The 18 migration collections are
|
|
49
|
+
defined by `define_method` over `Migration::COLLECTIONS`, so no path literal
|
|
50
|
+
exists to scan. They are listed in `.api-coverage.yml` and marked
|
|
51
|
+
*(generated)* below. That file is produced from the gem's own constants — it
|
|
52
|
+
is not a hand-written promise.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Endpoint map
|
|
57
|
+
|
|
58
|
+
Every operation in the API, and the gem method that issues it. Grouped as the
|
|
59
|
+
spec groups them.
|
|
60
|
+
|
|
61
|
+
### Discovery — 4/4
|
|
62
|
+
|
|
63
|
+
Introspection endpoints. Useful as a deploy-time smoke check and as the entry
|
|
64
|
+
point for agents and CLIs.
|
|
65
|
+
|
|
66
|
+
| Method | Path | Gem |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| GET | `/api/v1/whoami` | `Discovery#whoami` — also `client.whoami` |
|
|
69
|
+
| GET | `/api/v1/status` | `Discovery#status` — also `client.status` |
|
|
70
|
+
| GET | `/api/v1/prime` | `Discovery#prime` — also `client.prime` |
|
|
71
|
+
| GET | `/api/v1/skill` | `Discovery#skill` — also `client.skill`; returns a String, not a Hash |
|
|
72
|
+
|
|
73
|
+
### Subscribers — 11/11
|
|
74
|
+
|
|
75
|
+
| Method | Path | Gem |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| GET | `/api/v1/subscribers` | `Subscribers#list` |
|
|
78
|
+
| GET | `/api/v1/subscribers/find` | `Subscribers#find` |
|
|
79
|
+
| POST | `/api/v1/subscribers` | `Subscribers#create` |
|
|
80
|
+
| PATCH | `/api/v1/subscribers` | `Subscribers#update` |
|
|
81
|
+
| POST | `/api/v1/subscribers/add_tag` | `Subscribers#add_tags` |
|
|
82
|
+
| DELETE | `/api/v1/subscribers/remove_tag` | `Subscribers#remove_tags` |
|
|
83
|
+
| POST | `/api/v1/subscribers/activate` | `Subscribers#activate` |
|
|
84
|
+
| POST | `/api/v1/subscribers/deactivate` | `Subscribers#deactivate` |
|
|
85
|
+
| POST | `/api/v1/subscribers/unsubscribe` | `Subscribers#unsubscribe` |
|
|
86
|
+
| POST | `/api/v1/subscribers/resubscribe` | `Subscribers#resubscribe` |
|
|
87
|
+
| POST | `/api/v1/subscribers/redact` | `Subscribers#redact` |
|
|
88
|
+
|
|
89
|
+
Subscribers are addressed by **email**, not id — there is no
|
|
90
|
+
`/api/v1/subscribers/{id}`. `list` supports eight filters (`is_active`,
|
|
91
|
+
`source`, `created_after`, `created_before`, `tags`, `email` partial match,
|
|
92
|
+
`confirmation_status`, `custom_data` JSONB containment).
|
|
93
|
+
|
|
94
|
+
There is no destroy endpoint by design; `redact` is the GDPR erasure path and
|
|
95
|
+
preserves aggregate statistics.
|
|
96
|
+
|
|
97
|
+
### Broadcasts — 8/8, plus statistics 3/3
|
|
98
|
+
|
|
99
|
+
| Method | Path | Gem |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| GET | `/api/v1/broadcasts` | `Broadcasts#list` |
|
|
102
|
+
| GET | `/api/v1/broadcasts/{id}` | `Broadcasts#get_broadcast` |
|
|
103
|
+
| POST | `/api/v1/broadcasts` | `Broadcasts#create` |
|
|
104
|
+
| PATCH | `/api/v1/broadcasts/{id}` | `Broadcasts#update` |
|
|
105
|
+
| DELETE | `/api/v1/broadcasts/{id}` | `Broadcasts#delete` |
|
|
106
|
+
| POST | `/api/v1/broadcasts/{id}/send_broadcast` | `Broadcasts#send_broadcast` |
|
|
107
|
+
| POST | `/api/v1/broadcasts/{id}/schedule_broadcast` | `Broadcasts#schedule` |
|
|
108
|
+
| POST | `/api/v1/broadcasts/{id}/cancel_schedule` | `Broadcasts#cancel_schedule` |
|
|
109
|
+
| GET | `/api/v1/broadcasts/{id}/statistics` | `Broadcasts#statistics` |
|
|
110
|
+
| GET | `/api/v1/broadcasts/{id}/statistics/timeline` | `Broadcasts#statistics_timeline` |
|
|
111
|
+
| GET | `/api/v1/broadcasts/{id}/statistics/links` | `Broadcasts#statistics_links` |
|
|
112
|
+
|
|
113
|
+
### Sequences — 8/8, plus steps 6/6
|
|
114
|
+
|
|
115
|
+
| Method | Path | Gem |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| GET | `/api/v1/sequences` | `Sequences#list` |
|
|
118
|
+
| GET | `/api/v1/sequences/{id}` | `Sequences#get_sequence` |
|
|
119
|
+
| POST | `/api/v1/sequences` | `Sequences#create` |
|
|
120
|
+
| PATCH | `/api/v1/sequences/{id}` | `Sequences#update` |
|
|
121
|
+
| DELETE | `/api/v1/sequences/{id}` | `Sequences#delete` |
|
|
122
|
+
| POST | `/api/v1/sequences/{id}/add_subscriber` | `Sequences#add_subscriber` |
|
|
123
|
+
| DELETE | `/api/v1/sequences/{id}/remove_subscriber` | `Sequences#remove_subscriber` |
|
|
124
|
+
| GET | `/api/v1/sequences/{id}/list_subscribers` | `Sequences#list_subscribers` |
|
|
125
|
+
| GET | `/api/v1/sequences/{id}/steps` | `Sequences#list_steps` |
|
|
126
|
+
| GET | `/api/v1/sequences/{id}/steps/{step_id}` | `Sequences#get_step` |
|
|
127
|
+
| POST | `/api/v1/sequences/{id}/steps` | `Sequences#create_step` |
|
|
128
|
+
| PATCH | `/api/v1/sequences/{id}/steps/{step_id}` | `Sequences#update_step` |
|
|
129
|
+
| DELETE | `/api/v1/sequences/{id}/steps/{step_id}` | `Sequences#delete_step` |
|
|
130
|
+
| POST | `/api/v1/sequences/{id}/steps/{step_id}/move` | `Sequences#move_step` |
|
|
131
|
+
|
|
132
|
+
Steps form a tree linked by `parent_id`, not a flat list — `move_step` reparents
|
|
133
|
+
rather than reorders.
|
|
134
|
+
|
|
135
|
+
### Transactionals — 2/2
|
|
136
|
+
|
|
137
|
+
| Method | Path | Gem |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| POST | `/api/v1/transactionals` | `Transactionals#create` — also `client.send_email` |
|
|
140
|
+
| GET | `/api/v1/transactionals/{id}` | `Transactionals#get_transactional` — also `client.get_email` |
|
|
141
|
+
|
|
142
|
+
The only endpoint accepting `Idempotency-Key`. Also reachable through
|
|
143
|
+
ActionMailer via the `:broadcast` delivery method.
|
|
144
|
+
|
|
145
|
+
### Templates — 5/5
|
|
146
|
+
|
|
147
|
+
| Method | Path | Gem |
|
|
148
|
+
|---|---|---|
|
|
149
|
+
| GET | `/api/v1/templates` | `Templates#list` |
|
|
150
|
+
| GET | `/api/v1/templates/{id}` | `Templates#get_template` |
|
|
151
|
+
| POST | `/api/v1/templates` | `Templates#create` |
|
|
152
|
+
| PATCH | `/api/v1/templates/{id}` | `Templates#update` |
|
|
153
|
+
| DELETE | `/api/v1/templates/{id}` | `Templates#delete` |
|
|
154
|
+
|
|
155
|
+
### Segments — 5/5
|
|
156
|
+
|
|
157
|
+
| Method | Path | Gem |
|
|
158
|
+
|---|---|---|
|
|
159
|
+
| GET | `/api/v1/segments` | `Segments#list` |
|
|
160
|
+
| GET | `/api/v1/segments/{id}` | `Segments#get_segment` |
|
|
161
|
+
| POST | `/api/v1/segments` | `Segments#create` |
|
|
162
|
+
| PATCH | `/api/v1/segments/{id}` | `Segments#update` |
|
|
163
|
+
| DELETE | `/api/v1/segments/{id}` | `Segments#delete` |
|
|
164
|
+
|
|
165
|
+
`get_segment` returns the segment's *matching subscribers*, paginated, and
|
|
166
|
+
recounts on read — it is a query, not a cheap fetch.
|
|
167
|
+
|
|
168
|
+
### Opt-in forms — 8/8
|
|
169
|
+
|
|
170
|
+
| Method | Path | Gem |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| GET | `/api/v1/opt_in_forms` | `OptInForms#list` |
|
|
173
|
+
| GET | `/api/v1/opt_in_forms/{id}` | `OptInForms#get_opt_in_form` |
|
|
174
|
+
| POST | `/api/v1/opt_in_forms` | `OptInForms#create` |
|
|
175
|
+
| PATCH | `/api/v1/opt_in_forms/{id}` | `OptInForms#update` |
|
|
176
|
+
| DELETE | `/api/v1/opt_in_forms/{id}` | `OptInForms#delete` |
|
|
177
|
+
| GET | `/api/v1/opt_in_forms/{id}/analytics` | `OptInForms#analytics` |
|
|
178
|
+
| POST | `/api/v1/opt_in_forms/{id}/variants` | `OptInForms#create_variant` |
|
|
179
|
+
| POST | `/api/v1/opt_in_forms/{id}/duplicate` | `OptInForms#duplicate` |
|
|
180
|
+
|
|
181
|
+
`list` returns main forms only — A/B variants are excluded.
|
|
182
|
+
|
|
183
|
+
### Email servers — 7/7
|
|
184
|
+
|
|
185
|
+
| Method | Path | Gem |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| GET | `/api/v1/email_servers` | `EmailServers#list` |
|
|
188
|
+
| GET | `/api/v1/email_servers/{id}` | `EmailServers#get_email_server` |
|
|
189
|
+
| POST | `/api/v1/email_servers` | `EmailServers#create` |
|
|
190
|
+
| PATCH | `/api/v1/email_servers/{id}` | `EmailServers#update` |
|
|
191
|
+
| DELETE | `/api/v1/email_servers/{id}` | `EmailServers#delete` |
|
|
192
|
+
| POST | `/api/v1/email_servers/{id}/test_connection` | `EmailServers#test_connection` |
|
|
193
|
+
| POST | `/api/v1/email_servers/{id}/copy_to_channel` | `EmailServers#copy_to_channel` — admin token |
|
|
194
|
+
|
|
195
|
+
`update` carries a **credential-redaction guard**: the API returns credential
|
|
196
|
+
fields bullet-masked, and the gem strips values matching that shape out of the
|
|
197
|
+
payload so a naive fetch-modify-save cannot overwrite a real SMTP password with
|
|
198
|
+
`••••••••`. This is behaviour the API does not provide — it is the gem
|
|
199
|
+
protecting you from a data-loss bug.
|
|
200
|
+
|
|
201
|
+
### Webhook endpoints — 7/7
|
|
202
|
+
|
|
203
|
+
| Method | Path | Gem |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| GET | `/api/v1/webhook_endpoints` | `WebhookEndpoints#list` |
|
|
206
|
+
| GET | `/api/v1/webhook_endpoints/{id}` | `WebhookEndpoints#get_endpoint` |
|
|
207
|
+
| POST | `/api/v1/webhook_endpoints` | `WebhookEndpoints#create` |
|
|
208
|
+
| PATCH | `/api/v1/webhook_endpoints/{id}` | `WebhookEndpoints#update` |
|
|
209
|
+
| DELETE | `/api/v1/webhook_endpoints/{id}` | `WebhookEndpoints#delete` |
|
|
210
|
+
| POST | `/api/v1/webhook_endpoints/{id}/test` | `WebhookEndpoints#test` |
|
|
211
|
+
| GET | `/api/v1/webhook_endpoints/{id}/deliveries` | `WebhookEndpoints#deliveries` |
|
|
212
|
+
|
|
213
|
+
The `secret` is returned once, on create, and never again.
|
|
214
|
+
|
|
215
|
+
### Autopilot — 10/10
|
|
216
|
+
|
|
217
|
+
AI-generated newsletters. Requires the `autopilot_read` / `autopilot_write`
|
|
218
|
+
token permissions.
|
|
219
|
+
|
|
220
|
+
| Method | Path | Gem |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| GET | `/api/v1/autopilots` | `Autopilots#list` |
|
|
223
|
+
| GET | `/api/v1/autopilots/{id}` | `Autopilots#get_autopilot` |
|
|
224
|
+
| POST | `/api/v1/autopilots` | `Autopilots#create` |
|
|
225
|
+
| PATCH | `/api/v1/autopilots/{id}` | `Autopilots#update` |
|
|
226
|
+
| DELETE | `/api/v1/autopilots/{id}` | `Autopilots#delete` |
|
|
227
|
+
| POST | `/api/v1/autopilots/{id}/activate` | `Autopilots#activate` |
|
|
228
|
+
| POST | `/api/v1/autopilots/{id}/pause` | `Autopilots#pause` |
|
|
229
|
+
| POST | `/api/v1/autopilots/{id}/deactivate` | `Autopilots#deactivate` |
|
|
230
|
+
| POST | `/api/v1/autopilots/{id}/trigger_run` | `Autopilots#trigger_run` |
|
|
231
|
+
| GET | `/api/v1/autopilots/{id}/runs` | `Autopilots#runs` |
|
|
232
|
+
|
|
233
|
+
`openrouter_api_key` is never returned — it is encrypted at rest, and `show`
|
|
234
|
+
reports only `api_key_configured`. `update` carries the same **credential
|
|
235
|
+
redaction guard** as email servers: a bullet-masked key is stripped from the
|
|
236
|
+
payload so a fetch-modify-save cannot destroy the stored credential.
|
|
237
|
+
|
|
238
|
+
`activate` requires an active source, an API key, and a model; it returns 422
|
|
239
|
+
naming the missing prerequisites. `trigger_run` returns 202 — generation is
|
|
240
|
+
asynchronous, so poll `runs`.
|
|
241
|
+
|
|
242
|
+
### Migration / export — 20/20
|
|
243
|
+
|
|
244
|
+
Admin tokens only. `broadcast_channel_id` is required on every call; set it once
|
|
245
|
+
on the client and the gem attaches it.
|
|
246
|
+
|
|
247
|
+
| Method | Path | Gem |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| GET | `/api/migration/v1/manifest` | `Migration#manifest` |
|
|
250
|
+
| GET | `/api/migration/v1/file_assets/{id}/download` | `Migration#download_file_asset` — raw bytes |
|
|
251
|
+
| GET | `/api/migration/v1/channels` | *(generated)* `Migration#channels` |
|
|
252
|
+
| GET | `/api/migration/v1/subscribers` | *(generated)* `Migration#subscribers` |
|
|
253
|
+
| GET | `/api/migration/v1/templates` | *(generated)* `Migration#templates` |
|
|
254
|
+
| GET | `/api/migration/v1/segments` | *(generated)* `Migration#segments` |
|
|
255
|
+
| GET | `/api/migration/v1/sequences` | *(generated)* `Migration#sequences` |
|
|
256
|
+
| GET | `/api/migration/v1/email_servers` | *(generated)* `Migration#email_servers` |
|
|
257
|
+
| GET | `/api/migration/v1/opt_in_forms` | *(generated)* `Migration#opt_in_forms` |
|
|
258
|
+
| GET | `/api/migration/v1/broadcasts` | *(generated)* `Migration#broadcasts` |
|
|
259
|
+
| GET | `/api/migration/v1/outbound_receipts` | *(generated)* `Migration#outbound_receipts` |
|
|
260
|
+
| GET | `/api/migration/v1/webhook_endpoints` | *(generated)* `Migration#webhook_endpoints` |
|
|
261
|
+
| GET | `/api/migration/v1/tokens` | *(generated)* `Migration#tokens` |
|
|
262
|
+
| GET | `/api/migration/v1/suppressions` | *(generated)* `Migration#suppressions` |
|
|
263
|
+
| GET | `/api/migration/v1/tags` | *(generated)* `Migration#tags` |
|
|
264
|
+
| GET | `/api/migration/v1/users` | *(generated)* `Migration#users` |
|
|
265
|
+
| GET | `/api/migration/v1/link_redirects` | *(generated)* `Migration#link_redirects` |
|
|
266
|
+
| GET | `/api/migration/v1/link_clicks` | *(generated)* `Migration#link_clicks` |
|
|
267
|
+
| GET | `/api/migration/v1/subscriber_histories` | *(generated)* `Migration#subscriber_histories` |
|
|
268
|
+
| GET | `/api/migration/v1/file_assets` | *(generated)* `Migration#file_assets` |
|
|
269
|
+
|
|
270
|
+
`Migration#each_record(:collection)` pages any of them automatically, advancing
|
|
271
|
+
by the page size the **server granted** rather than the one requested (the
|
|
272
|
+
server clamps `limit` to 250).
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## Transport coverage
|
|
277
|
+
|
|
278
|
+
Endpoint coverage is the easy half. These are the protocol behaviours the API
|
|
279
|
+
expects a well-behaved client to implement — and where a client can look
|
|
280
|
+
complete while quietly losing information.
|
|
281
|
+
|
|
282
|
+
| Capability | Status | Gem surface |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| Bearer auth | ✅ | `api_token:` / `BROADCAST_API_TOKEN` |
|
|
285
|
+
| Explicit host, no default | ✅ | `host:` / `BROADCAST_HOST`, required |
|
|
286
|
+
| `User-Agent` attribution | ✅ | `broadcast-ruby/<version>` |
|
|
287
|
+
| **`warnings` array** | ✅ | `result.warnings`, `warnings_mode:` `:log`/`:raise`/`:ignore` |
|
|
288
|
+
| **`Idempotency-Key`** | ✅ | `transactionals.create(idempotency_key:)` |
|
|
289
|
+
| **`Idempotency-Replayed`** | ✅ | `result.idempotent_replay?` |
|
|
290
|
+
| **Rate-limit headers** | ✅ | `result.rate_limit` — limit, remaining, reset |
|
|
291
|
+
| **429 + `Retry-After`** | ✅ | Retried, bounded by `max_retry_delay` |
|
|
292
|
+
| Retry on 5xx and timeout | ✅ | `retry_attempts`, `retry_delay` |
|
|
293
|
+
| Typed errors | ✅ | 401/403/404/409/422/429/5xx each distinct |
|
|
294
|
+
| Redirect handling | ✅ | Same-host GET follows; writes and cross-host refuse |
|
|
295
|
+
| Raw/text responses | ✅ | `client.skill`, `download_file_asset` |
|
|
296
|
+
| Channel scoping | ✅ | `broadcast_channel_id:`, `with_channel(id) { }` |
|
|
297
|
+
| Debug logging | ✅ | Credentials redacted |
|
|
298
|
+
| Pagination helper | ⚠️ Migration only | `each_record`; v1 endpoints page manually |
|
|
299
|
+
| ActionMailer integration | ✅ | `:broadcast` delivery method + Railtie |
|
|
300
|
+
|
|
301
|
+
Error mapping in full:
|
|
302
|
+
|
|
303
|
+
| HTTP | Exception |
|
|
304
|
+
|---|---|
|
|
305
|
+
| 401 | `Broadcast::AuthenticationError` |
|
|
306
|
+
| 403 | `Broadcast::AuthorizationError` |
|
|
307
|
+
| 404 | `Broadcast::NotFoundError` |
|
|
308
|
+
| 409 | `Broadcast::ConflictError` — idempotency replay in flight |
|
|
309
|
+
| 422 | `Broadcast::ValidationError` |
|
|
310
|
+
| 429 | `Broadcast::RateLimitError` — carries `#retry_after` |
|
|
311
|
+
| 5xx | `Broadcast::APIError` |
|
|
312
|
+
| — | `Broadcast::TimeoutError`, `ConfigurationError`, `WarningError`, `DeliveryError` |
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Webhook coverage
|
|
317
|
+
|
|
318
|
+
Inbound webhooks — the app calling you.
|
|
319
|
+
|
|
320
|
+
| Capability | Status |
|
|
321
|
+
|---|---|
|
|
322
|
+
| HMAC-SHA256 verification (`v1,<base64>`) | ✅ `Webhook.verify` |
|
|
323
|
+
| 5-minute timestamp tolerance | ✅ Replay protection |
|
|
324
|
+
| Constant-time comparison | ✅ `OpenSSL.fixed_length_secure_compare` |
|
|
325
|
+
| Event type constants | ✅ `Webhook::EVENT_TYPES` — 32 values |
|
|
326
|
+
|
|
327
|
+
Categories: `EMAIL_EVENTS` (8), `SUBSCRIBER_EVENTS` (7), `BROADCAST_EVENTS` (8),
|
|
328
|
+
`SEQUENCE_EVENTS` (7), `SYSTEM_EVENTS` (2).
|
|
329
|
+
|
|
330
|
+
Not covered: payload deserialisation into typed objects. You get the parsed
|
|
331
|
+
JSON body and dispatch on `type` yourself.
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## Not in the API at all
|
|
336
|
+
|
|
337
|
+
These are Broadcast features with no v1 endpoints. **No client can reach them** —
|
|
338
|
+
this is an API gap, not a gem gap, and nothing here is actionable in this repo.
|
|
339
|
+
|
|
340
|
+
| Feature | Where it lives | Notes |
|
|
341
|
+
|---|---|---|
|
|
342
|
+
| **Autopilot** (AI newsletter generation) | Web UI | Sources, tone samples, runs, scheduling. See below — this one is different. |
|
|
343
|
+
| **Ask AI** | Web UI | Conversations, messages, tool-call approval |
|
|
344
|
+
| **Subscriber imports** | Web UI | CSV/TSV upload, mapping, processing |
|
|
345
|
+
| **CSV exports** | Web UI | Subscribers, broadcasts, activity. `GET /subscribers/export` is a web route |
|
|
346
|
+
| **Bulk operations** | Web UI | Subscriber, template, and file-asset bulk actions; broadcast bulk archive |
|
|
347
|
+
| **Channel management** | Web UI | Creating channels, domain/sender setup, onboarding |
|
|
348
|
+
| **User & permission management** | Web UI | Users, system permissions, per-channel permissions |
|
|
349
|
+
| **Settings** | Web UI | General, AI, DNS checks, ESP integrations, sender details |
|
|
350
|
+
| **Global suppressions** | Web UI + migration export | Read-only via `migration.suppressions`; no v1 CRUD |
|
|
351
|
+
| **File assets** | Web UI + migration export | Read-only via migration; no v1 upload |
|
|
352
|
+
| **Link tracking config** | Web UI | Click data readable via `statistics_links` and migration |
|
|
353
|
+
| **Admin / platform** | Web UI | Admin API tokens, SaaS platform admin |
|
|
354
|
+
| **Confirmation templates & pages** | Partly API | Configurable as template attributes; no dedicated endpoints |
|
|
355
|
+
|
|
356
|
+
### Autopilot is worth a separate note
|
|
357
|
+
|
|
358
|
+
The `Token` model already defines **`permission_to_autopilot_read`** and
|
|
359
|
+
**`permission_to_autopilot_write`** — but there is no `api/v1/autopilots`
|
|
360
|
+
controller and zero autopilot routes under `/api`. The permission flags exist
|
|
361
|
+
with nothing behind them.
|
|
362
|
+
|
|
363
|
+
So an operator can grant an API token "Autopilot" access today and that grant
|
|
364
|
+
does nothing. That is worth either building the endpoints or hiding the
|
|
365
|
+
permission — and it is the most likely next addition to the API surface, which
|
|
366
|
+
makes it the first thing to check when this document goes stale.
|
|
367
|
+
|
|
368
|
+
---
|
|
369
|
+
|
|
370
|
+
## Limits of the 100%
|
|
371
|
+
|
|
372
|
+
The figure means **every operation the router serves has a corresponding gem
|
|
373
|
+
method**. It does not mean the gem is complete. Specifically, it does not measure:
|
|
374
|
+
|
|
375
|
+
**Parameter completeness.** This is the important one. Resource methods take
|
|
376
|
+
`**attrs` and forward them, so a method covers its endpoint regardless of which
|
|
377
|
+
attributes it knows about. The v2.19 additions — template `confirmation_page_settings`,
|
|
378
|
+
opt-in-form `welcome_email_template_id`, admin-only `confirmed_at` on subscriber
|
|
379
|
+
create — all "passed through" at 100% coverage while being entirely undocumented.
|
|
380
|
+
They are documented now, but the metric would not have caught them and will not
|
|
381
|
+
catch the next ones. Closing this needs response schemas in `openapi/overlay.yml`
|
|
382
|
+
and a parameter-level check.
|
|
383
|
+
|
|
384
|
+
**Response shapes.** The gem returns parsed JSON. It does not model or validate
|
|
385
|
+
response bodies, so an API field that changes type is invisible to it.
|
|
386
|
+
|
|
387
|
+
**Semantics.** Coverage says a method issues the right request, not that it
|
|
388
|
+
handles the endpoint's behaviour correctly. `list` returning only main opt-in
|
|
389
|
+
forms, or `get_segment` recounting on read, are documented in comments — not
|
|
390
|
+
enforced by anything.
|
|
391
|
+
|
|
392
|
+
**Live correctness.** The suite mocks HTTP. `rake test_live` exercises a real
|
|
393
|
+
instance and is **not** part of the coverage figure; it needs a token and host.
|
|
394
|
+
|
|
395
|
+
### Known unevenness
|
|
396
|
+
|
|
397
|
+
- **Pagination.** `Migration#each_record` auto-pages. The v1 endpoints do not
|
|
398
|
+
have an equivalent — callers loop on `page:` themselves.
|
|
399
|
+
- **Naming.** Getters are inconsistent: `get_broadcast`, `get_segment`,
|
|
400
|
+
`get_endpoint`, `get_opt_in_form`, `get_email_server`, `get_template`,
|
|
401
|
+
`get_transactional`, `get_step`. `Broadcast` collides with the top-level
|
|
402
|
+
module name, which is what forced the prefix originally; the rest followed for
|
|
403
|
+
symmetry. Worth settling before other SDKs copy the pattern.
|
|
404
|
+
- **Steps live on `Sequences`.** There is no `client.sequence_steps`; step
|
|
405
|
+
methods hang off the sequences resource, matching the nested routes.
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
## Keeping this current
|
|
410
|
+
|
|
411
|
+
This file is written by hand from generated data. Regenerate the underlying
|
|
412
|
+
numbers with:
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
# in the broadcast repo
|
|
416
|
+
bin/rails openapi:generate
|
|
417
|
+
bin/rails "openapi:coverage[../broadcast-ruby]"
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
If `openapi:check` fails in CI, the API changed and this document is stale.
|
|
421
|
+
Cross-language plans live in [SDK-TODO.md](SDK-TODO.md); the v0.3.0 work log is
|
|
422
|
+
in [TODO.md](TODO.md).
|
data/lib/broadcast/client.rb
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'net/http'
|
|
4
|
-
require 'json'
|
|
5
|
-
require 'uri'
|
|
6
|
-
|
|
7
3
|
module Broadcast
|
|
8
4
|
class Client
|
|
9
5
|
CHANNEL_OVERRIDE_KEY = :__broadcast_ruby_channel_override
|
|
@@ -14,6 +10,7 @@ module Broadcast
|
|
|
14
10
|
@config = Configuration.new
|
|
15
11
|
settings.each { |k, v| @config.public_send(:"#{k}=", v) }
|
|
16
12
|
@config.validate!
|
|
13
|
+
@connection = Connection.new(@config)
|
|
17
14
|
end
|
|
18
15
|
|
|
19
16
|
# --- Channel scoping (admin/system tokens) ---
|
|
@@ -38,7 +35,7 @@ module Broadcast
|
|
|
38
35
|
|
|
39
36
|
# Thin convenience wrapper around `transactionals.create`. Use
|
|
40
37
|
# `client.transactionals.create` directly for template_id, double_opt_in,
|
|
41
|
-
# preheader, and other advanced options.
|
|
38
|
+
# preheader, idempotency_key, and other advanced options.
|
|
42
39
|
def send_email(to:, subject: nil, body: nil, reply_to: nil)
|
|
43
40
|
transactionals.create(to: to, subject: subject, body: body, reply_to: reply_to)
|
|
44
41
|
end
|
|
@@ -47,6 +44,24 @@ module Broadcast
|
|
|
47
44
|
transactionals.get_transactional(id)
|
|
48
45
|
end
|
|
49
46
|
|
|
47
|
+
# --- Discovery (convenience shims) ---
|
|
48
|
+
|
|
49
|
+
def whoami
|
|
50
|
+
discovery.whoami
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def status
|
|
54
|
+
discovery.status
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def prime
|
|
58
|
+
discovery.prime
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def skill
|
|
62
|
+
discovery.skill
|
|
63
|
+
end
|
|
64
|
+
|
|
50
65
|
# --- Resource sub-clients ---
|
|
51
66
|
|
|
52
67
|
def subscribers
|
|
@@ -85,14 +100,24 @@ module Broadcast
|
|
|
85
100
|
@email_servers ||= Resources::EmailServers.new(self)
|
|
86
101
|
end
|
|
87
102
|
|
|
103
|
+
def autopilots
|
|
104
|
+
@autopilots ||= Resources::Autopilots.new(self)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def discovery
|
|
108
|
+
@discovery ||= Resources::Discovery.new(self)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Read-only export endpoints under /api/migration/v1. Requires an admin
|
|
112
|
+
# (system) API token.
|
|
113
|
+
def migration
|
|
114
|
+
@migration ||= Resources::Migration.new(self)
|
|
115
|
+
end
|
|
116
|
+
|
|
88
117
|
# @api private
|
|
89
|
-
def request(method, path, body_or_params = nil)
|
|
118
|
+
def request(method, path, body_or_params = nil, headers: {}, raw: false)
|
|
90
119
|
payload = inject_channel_scope(body_or_params)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
retry_with_backoff { execute(method, uri, payload) }
|
|
94
|
-
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
95
|
-
raise Broadcast::TimeoutError, "Request timeout: #{e.message}"
|
|
120
|
+
@connection.request(method, path, payload, headers: headers, raw: raw)
|
|
96
121
|
end
|
|
97
122
|
|
|
98
123
|
private
|
|
@@ -117,130 +142,5 @@ module Broadcast
|
|
|
117
142
|
payload[:broadcast_channel_id] = channel_id
|
|
118
143
|
payload
|
|
119
144
|
end
|
|
120
|
-
|
|
121
|
-
def build_uri(path, method, payload)
|
|
122
|
-
uri = URI("#{@config.host}#{path}")
|
|
123
|
-
uri.query = URI.encode_www_form(flatten_params(payload)) if method == :get && payload_present?(payload)
|
|
124
|
-
uri
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def execute(method, uri, payload)
|
|
128
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
129
|
-
http.use_ssl = uri.scheme == 'https'
|
|
130
|
-
http.open_timeout = @config.open_timeout
|
|
131
|
-
http.read_timeout = @config.timeout
|
|
132
|
-
|
|
133
|
-
req = build_request(method, uri)
|
|
134
|
-
req.body = payload.to_json if method != :get && payload_present?(payload)
|
|
135
|
-
|
|
136
|
-
log_request(req, method == :get ? nil : payload) if @config.debug
|
|
137
|
-
response = http.request(req)
|
|
138
|
-
log_response(response) if @config.debug
|
|
139
|
-
handle_response(response)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def payload_present?(payload)
|
|
143
|
-
payload.is_a?(Hash) && payload.any?
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def build_request(method, uri)
|
|
147
|
-
klass = case method
|
|
148
|
-
when :get then Net::HTTP::Get
|
|
149
|
-
when :post then Net::HTTP::Post
|
|
150
|
-
when :patch then Net::HTTP::Patch
|
|
151
|
-
when :delete then Net::HTTP::Delete
|
|
152
|
-
else raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
req = klass.new(uri)
|
|
156
|
-
req['Authorization'] = "Bearer #{@config.api_token}"
|
|
157
|
-
req['Content-Type'] = 'application/json'
|
|
158
|
-
req['User-Agent'] = "broadcast-ruby/#{Broadcast::VERSION}"
|
|
159
|
-
req
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
ERROR_MAPPING = {
|
|
163
|
-
401 => [AuthenticationError, 'Authentication failed'],
|
|
164
|
-
403 => [AuthorizationError, 'Not authorized'],
|
|
165
|
-
404 => [NotFoundError, 'Resource not found'],
|
|
166
|
-
422 => [ValidationError, 'Validation failed'],
|
|
167
|
-
429 => [RateLimitError, 'Rate limit exceeded']
|
|
168
|
-
}.freeze
|
|
169
|
-
SERVER_ERROR_CODES = [500, 502, 503, 504].freeze
|
|
170
|
-
private_constant :ERROR_MAPPING, :SERVER_ERROR_CODES
|
|
171
|
-
|
|
172
|
-
def handle_response(response)
|
|
173
|
-
code = response.code.to_i
|
|
174
|
-
return parse_success_body(response) if [200, 201].include?(code)
|
|
175
|
-
|
|
176
|
-
if (mapping = ERROR_MAPPING[code])
|
|
177
|
-
klass, default = mapping
|
|
178
|
-
raise klass, parse_error(response) || default
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
raise APIError, parse_error(response) || "Server error (#{code})" if SERVER_ERROR_CODES.include?(code)
|
|
182
|
-
|
|
183
|
-
raise APIError, parse_error(response) || "Unexpected response: #{code}"
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def parse_success_body(response)
|
|
187
|
-
return {} if response.body.nil? || response.body.strip.empty?
|
|
188
|
-
|
|
189
|
-
JSON.parse(response.body)
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
def parse_error(response)
|
|
193
|
-
JSON.parse(response.body)['error']
|
|
194
|
-
rescue JSON::ParserError
|
|
195
|
-
nil
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
def retry_with_backoff
|
|
199
|
-
attempts = 0
|
|
200
|
-
begin
|
|
201
|
-
attempts += 1
|
|
202
|
-
yield
|
|
203
|
-
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
204
|
-
raise if attempts >= @config.retry_attempts
|
|
205
|
-
|
|
206
|
-
sleep(@config.retry_delay * attempts)
|
|
207
|
-
retry
|
|
208
|
-
rescue APIError => e
|
|
209
|
-
raise unless attempts < @config.retry_attempts && e.message.include?('Server error')
|
|
210
|
-
|
|
211
|
-
sleep(@config.retry_delay * attempts)
|
|
212
|
-
retry
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
def flatten_params(params)
|
|
217
|
-
result = []
|
|
218
|
-
params.each do |key, value|
|
|
219
|
-
case value
|
|
220
|
-
when Array
|
|
221
|
-
value.each { |v| result << ["#{key}[]", v.to_s] }
|
|
222
|
-
when Hash
|
|
223
|
-
value.each { |k, v| result << ["#{key}[#{k}]", v.to_s] }
|
|
224
|
-
when nil
|
|
225
|
-
next
|
|
226
|
-
else
|
|
227
|
-
result << [key.to_s, value.to_s]
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
result
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def log_request(request, body)
|
|
234
|
-
return unless @config.logger
|
|
235
|
-
|
|
236
|
-
@config.logger.debug("[Broadcast] #{request.method} #{request.uri}")
|
|
237
|
-
@config.logger.debug("[Broadcast] Body: #{body.to_json}") if body.is_a?(Hash) && body.any?
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
def log_response(response)
|
|
241
|
-
return unless @config.logger
|
|
242
|
-
|
|
243
|
-
@config.logger.debug("[Broadcast] Response: #{response.code} #{response.body}")
|
|
244
|
-
end
|
|
245
145
|
end
|
|
246
146
|
end
|