broadcast-ruby 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +146 -0
- data/Gemfile.lock +3 -2
- data/README.md +434 -22
- data/SDK-COVERAGE.md +469 -0
- data/lib/broadcast/client.rb +65 -138
- 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 +22 -2
- 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/global_suppressions.rb +43 -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/suppressions.rb +56 -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 +8 -0
- metadata +11 -3
- data/.rubocop.yml +0 -50
data/SDK-COVERAGE.md
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
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
|
+
> **This file describes the Ruby gem specifically.** Since 2026-07-27 there are
|
|
7
|
+
> three sibling SDKs — `broadcast-node`, `broadcast-python`, `broadcast-php` —
|
|
8
|
+
> all at 104/104 against the same spec. The endpoint map below applies to all
|
|
9
|
+
> of them (the paths are identical); only the method names differ. Per-language
|
|
10
|
+
> status, including the caveat that PHP's suite has never been run, is in
|
|
11
|
+
> [SDK-TODO.md](SDK-TODO.md#status--2026-07-27).
|
|
12
|
+
|
|
13
|
+
**Headline: 104/104 API operations (100%).**
|
|
14
|
+
|
|
15
|
+
That number is narrower than it sounds, and [Limits of the 100%](#limits-of-the-100)
|
|
16
|
+
explains exactly what it does not measure. Read that section before quoting the
|
|
17
|
+
figure.
|
|
18
|
+
|
|
19
|
+
- Gem version: **0.3.0**
|
|
20
|
+
- API version: **2.20.0** (75 paths, 104 operations)
|
|
21
|
+
- Generated by: `bin/rails "openapi:coverage[../broadcast-ruby]"` in the
|
|
22
|
+
`broadcast` repo
|
|
23
|
+
- Verified: self-hosted and SaaS mode (the `/api` surface is identical in both)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Contents
|
|
28
|
+
|
|
29
|
+
1. [How coverage is measured](#how-coverage-is-measured)
|
|
30
|
+
2. [Endpoint map](#endpoint-map) — every operation → the method that calls it
|
|
31
|
+
3. [Transport coverage](#transport-coverage) — the protocol, not the endpoints
|
|
32
|
+
4. [Webhook coverage](#webhook-coverage) — inbound
|
|
33
|
+
5. [Not in the API at all](#not-in-the-api-at-all) — app features no client can reach
|
|
34
|
+
6. [Demo instances answer differently](#demo-instances-answer-differently)
|
|
35
|
+
7. [Limits of the 100%](#limits-of-the-100)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## How coverage is measured
|
|
40
|
+
|
|
41
|
+
The `broadcast` repo generates an OpenAPI 3.1 document from `config/routes.rb`
|
|
42
|
+
plus the `*_PARAM_KEYS` constants each controller declares, then scans this
|
|
43
|
+
gem's source for the paths it calls.
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# in the broadcast repo
|
|
47
|
+
bin/rails openapi:generate
|
|
48
|
+
bin/rails "openapi:coverage[../broadcast-ruby]"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Two details worth knowing when reading the map below:
|
|
52
|
+
|
|
53
|
+
- **PATCH is canonical.** Rails' `resources` routes `update` to both PATCH and
|
|
54
|
+
PUT. They are one operation; the generator drops the PUT alias. Counting both
|
|
55
|
+
would inflate the denominator by 8 and make this gem look 8 endpoints short.
|
|
56
|
+
- **Metaprogrammed endpoints are declared.** The 18 migration collections are
|
|
57
|
+
defined by `define_method` over `Migration::COLLECTIONS`, so no path literal
|
|
58
|
+
exists to scan. They are listed in `.api-coverage.yml` and marked
|
|
59
|
+
*(generated)* below. That file is produced from the gem's own constants — it
|
|
60
|
+
is not a hand-written promise.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Endpoint map
|
|
65
|
+
|
|
66
|
+
Every operation in the API, and the gem method that issues it. Grouped as the
|
|
67
|
+
spec groups them.
|
|
68
|
+
|
|
69
|
+
### Discovery — 4/4
|
|
70
|
+
|
|
71
|
+
Introspection endpoints. Useful as a deploy-time smoke check and as the entry
|
|
72
|
+
point for agents and CLIs.
|
|
73
|
+
|
|
74
|
+
| Method | Path | Gem |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| GET | `/api/v1/whoami` | `Discovery#whoami` — also `client.whoami` |
|
|
77
|
+
| GET | `/api/v1/status` | `Discovery#status` — also `client.status` |
|
|
78
|
+
| GET | `/api/v1/prime` | `Discovery#prime` — also `client.prime` |
|
|
79
|
+
| GET | `/api/v1/skill` | `Discovery#skill` — also `client.skill`; returns a String, not a Hash |
|
|
80
|
+
|
|
81
|
+
### Subscribers — 11/11
|
|
82
|
+
|
|
83
|
+
| Method | Path | Gem |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| GET | `/api/v1/subscribers` | `Subscribers#list` |
|
|
86
|
+
| GET | `/api/v1/subscribers/find` | `Subscribers#find` |
|
|
87
|
+
| POST | `/api/v1/subscribers` | `Subscribers#create` |
|
|
88
|
+
| PATCH | `/api/v1/subscribers` | `Subscribers#update` |
|
|
89
|
+
| POST | `/api/v1/subscribers/add_tag` | `Subscribers#add_tags` |
|
|
90
|
+
| DELETE | `/api/v1/subscribers/remove_tag` | `Subscribers#remove_tags` |
|
|
91
|
+
| POST | `/api/v1/subscribers/activate` | `Subscribers#activate` |
|
|
92
|
+
| POST | `/api/v1/subscribers/deactivate` | `Subscribers#deactivate` |
|
|
93
|
+
| POST | `/api/v1/subscribers/unsubscribe` | `Subscribers#unsubscribe` |
|
|
94
|
+
| POST | `/api/v1/subscribers/resubscribe` | `Subscribers#resubscribe` |
|
|
95
|
+
| POST | `/api/v1/subscribers/redact` | `Subscribers#redact` |
|
|
96
|
+
|
|
97
|
+
Subscribers are addressed by **email**, not id — there is no
|
|
98
|
+
`/api/v1/subscribers/{id}`. `list` supports eight filters (`is_active`,
|
|
99
|
+
`source`, `created_after`, `created_before`, `tags`, `email` partial match,
|
|
100
|
+
`confirmation_status`, `custom_data` JSONB containment).
|
|
101
|
+
|
|
102
|
+
There is no destroy endpoint by design; `redact` is the GDPR erasure path and
|
|
103
|
+
preserves aggregate statistics.
|
|
104
|
+
|
|
105
|
+
### Broadcasts — 8/8, plus statistics 3/3
|
|
106
|
+
|
|
107
|
+
| Method | Path | Gem |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| GET | `/api/v1/broadcasts` | `Broadcasts#list` |
|
|
110
|
+
| GET | `/api/v1/broadcasts/{id}` | `Broadcasts#get_broadcast` |
|
|
111
|
+
| POST | `/api/v1/broadcasts` | `Broadcasts#create` |
|
|
112
|
+
| PATCH | `/api/v1/broadcasts/{id}` | `Broadcasts#update` |
|
|
113
|
+
| DELETE | `/api/v1/broadcasts/{id}` | `Broadcasts#delete` |
|
|
114
|
+
| POST | `/api/v1/broadcasts/{id}/send_broadcast` | `Broadcasts#send_broadcast` |
|
|
115
|
+
| POST | `/api/v1/broadcasts/{id}/schedule_broadcast` | `Broadcasts#schedule` |
|
|
116
|
+
| POST | `/api/v1/broadcasts/{id}/cancel_schedule` | `Broadcasts#cancel_schedule` |
|
|
117
|
+
| GET | `/api/v1/broadcasts/{id}/statistics` | `Broadcasts#statistics` |
|
|
118
|
+
| GET | `/api/v1/broadcasts/{id}/statistics/timeline` | `Broadcasts#statistics_timeline` |
|
|
119
|
+
| GET | `/api/v1/broadcasts/{id}/statistics/links` | `Broadcasts#statistics_links` |
|
|
120
|
+
|
|
121
|
+
### Sequences — 8/8, plus steps 6/6
|
|
122
|
+
|
|
123
|
+
| Method | Path | Gem |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| GET | `/api/v1/sequences` | `Sequences#list` |
|
|
126
|
+
| GET | `/api/v1/sequences/{id}` | `Sequences#get_sequence` |
|
|
127
|
+
| POST | `/api/v1/sequences` | `Sequences#create` |
|
|
128
|
+
| PATCH | `/api/v1/sequences/{id}` | `Sequences#update` |
|
|
129
|
+
| DELETE | `/api/v1/sequences/{id}` | `Sequences#delete` |
|
|
130
|
+
| POST | `/api/v1/sequences/{id}/add_subscriber` | `Sequences#add_subscriber` |
|
|
131
|
+
| DELETE | `/api/v1/sequences/{id}/remove_subscriber` | `Sequences#remove_subscriber` |
|
|
132
|
+
| GET | `/api/v1/sequences/{id}/list_subscribers` | `Sequences#list_subscribers` |
|
|
133
|
+
| GET | `/api/v1/sequences/{id}/steps` | `Sequences#list_steps` |
|
|
134
|
+
| GET | `/api/v1/sequences/{id}/steps/{step_id}` | `Sequences#get_step` |
|
|
135
|
+
| POST | `/api/v1/sequences/{id}/steps` | `Sequences#create_step` |
|
|
136
|
+
| PATCH | `/api/v1/sequences/{id}/steps/{step_id}` | `Sequences#update_step` |
|
|
137
|
+
| DELETE | `/api/v1/sequences/{id}/steps/{step_id}` | `Sequences#delete_step` |
|
|
138
|
+
| POST | `/api/v1/sequences/{id}/steps/{step_id}/move` | `Sequences#move_step` |
|
|
139
|
+
|
|
140
|
+
Steps form a tree linked by `parent_id`, not a flat list — `move_step` reparents
|
|
141
|
+
rather than reorders.
|
|
142
|
+
|
|
143
|
+
### Transactionals — 2/2
|
|
144
|
+
|
|
145
|
+
| Method | Path | Gem |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| POST | `/api/v1/transactionals` | `Transactionals#create` — also `client.send_email` |
|
|
148
|
+
| GET | `/api/v1/transactionals/{id}` | `Transactionals#get_transactional` — also `client.get_email` |
|
|
149
|
+
|
|
150
|
+
The only endpoint accepting `Idempotency-Key`. Also reachable through
|
|
151
|
+
ActionMailer via the `:broadcast` delivery method.
|
|
152
|
+
|
|
153
|
+
### Templates — 5/5
|
|
154
|
+
|
|
155
|
+
| Method | Path | Gem |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| GET | `/api/v1/templates` | `Templates#list` |
|
|
158
|
+
| GET | `/api/v1/templates/{id}` | `Templates#get_template` |
|
|
159
|
+
| POST | `/api/v1/templates` | `Templates#create` |
|
|
160
|
+
| PATCH | `/api/v1/templates/{id}` | `Templates#update` |
|
|
161
|
+
| DELETE | `/api/v1/templates/{id}` | `Templates#delete` |
|
|
162
|
+
|
|
163
|
+
### Segments — 5/5
|
|
164
|
+
|
|
165
|
+
| Method | Path | Gem |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| GET | `/api/v1/segments` | `Segments#list` |
|
|
168
|
+
| GET | `/api/v1/segments/{id}` | `Segments#get_segment` |
|
|
169
|
+
| POST | `/api/v1/segments` | `Segments#create` |
|
|
170
|
+
| PATCH | `/api/v1/segments/{id}` | `Segments#update` |
|
|
171
|
+
| DELETE | `/api/v1/segments/{id}` | `Segments#delete` |
|
|
172
|
+
|
|
173
|
+
`get_segment` returns the segment's *matching subscribers*, paginated, and
|
|
174
|
+
recounts on read — it is a query, not a cheap fetch.
|
|
175
|
+
|
|
176
|
+
### Opt-in forms — 8/8
|
|
177
|
+
|
|
178
|
+
| Method | Path | Gem |
|
|
179
|
+
|---|---|---|
|
|
180
|
+
| GET | `/api/v1/opt_in_forms` | `OptInForms#list` |
|
|
181
|
+
| GET | `/api/v1/opt_in_forms/{id}` | `OptInForms#get_opt_in_form` |
|
|
182
|
+
| POST | `/api/v1/opt_in_forms` | `OptInForms#create` |
|
|
183
|
+
| PATCH | `/api/v1/opt_in_forms/{id}` | `OptInForms#update` |
|
|
184
|
+
| DELETE | `/api/v1/opt_in_forms/{id}` | `OptInForms#delete` |
|
|
185
|
+
| GET | `/api/v1/opt_in_forms/{id}/analytics` | `OptInForms#analytics` |
|
|
186
|
+
| POST | `/api/v1/opt_in_forms/{id}/variants` | `OptInForms#create_variant` |
|
|
187
|
+
| POST | `/api/v1/opt_in_forms/{id}/duplicate` | `OptInForms#duplicate` |
|
|
188
|
+
|
|
189
|
+
`list` returns main forms only — A/B variants are excluded.
|
|
190
|
+
|
|
191
|
+
### Email servers — 7/7
|
|
192
|
+
|
|
193
|
+
| Method | Path | Gem |
|
|
194
|
+
|---|---|---|
|
|
195
|
+
| GET | `/api/v1/email_servers` | `EmailServers#list` |
|
|
196
|
+
| GET | `/api/v1/email_servers/{id}` | `EmailServers#get_email_server` |
|
|
197
|
+
| POST | `/api/v1/email_servers` | `EmailServers#create` |
|
|
198
|
+
| PATCH | `/api/v1/email_servers/{id}` | `EmailServers#update` |
|
|
199
|
+
| DELETE | `/api/v1/email_servers/{id}` | `EmailServers#delete` |
|
|
200
|
+
| POST | `/api/v1/email_servers/{id}/test_connection` | `EmailServers#test_connection` |
|
|
201
|
+
| POST | `/api/v1/email_servers/{id}/copy_to_channel` | `EmailServers#copy_to_channel` — admin token |
|
|
202
|
+
|
|
203
|
+
`update` carries a **credential-redaction guard**: the API returns credential
|
|
204
|
+
fields bullet-masked, and the gem strips values matching that shape out of the
|
|
205
|
+
payload so a naive fetch-modify-save cannot overwrite a real SMTP password with
|
|
206
|
+
`••••••••`. This is behaviour the API does not provide — it is the gem
|
|
207
|
+
protecting you from a data-loss bug.
|
|
208
|
+
|
|
209
|
+
### Webhook endpoints — 7/7
|
|
210
|
+
|
|
211
|
+
| Method | Path | Gem |
|
|
212
|
+
|---|---|---|
|
|
213
|
+
| GET | `/api/v1/webhook_endpoints` | `WebhookEndpoints#list` |
|
|
214
|
+
| GET | `/api/v1/webhook_endpoints/{id}` | `WebhookEndpoints#get_endpoint` |
|
|
215
|
+
| POST | `/api/v1/webhook_endpoints` | `WebhookEndpoints#create` |
|
|
216
|
+
| PATCH | `/api/v1/webhook_endpoints/{id}` | `WebhookEndpoints#update` |
|
|
217
|
+
| DELETE | `/api/v1/webhook_endpoints/{id}` | `WebhookEndpoints#delete` |
|
|
218
|
+
| POST | `/api/v1/webhook_endpoints/{id}/test` | `WebhookEndpoints#test` |
|
|
219
|
+
| GET | `/api/v1/webhook_endpoints/{id}/deliveries` | `WebhookEndpoints#deliveries` |
|
|
220
|
+
|
|
221
|
+
The `secret` is returned once, on create, and never again.
|
|
222
|
+
|
|
223
|
+
### Autopilot — 10/10
|
|
224
|
+
|
|
225
|
+
AI-generated newsletters. Requires the `autopilot_read` / `autopilot_write`
|
|
226
|
+
token permissions.
|
|
227
|
+
|
|
228
|
+
| Method | Path | Gem |
|
|
229
|
+
|---|---|---|
|
|
230
|
+
| GET | `/api/v1/autopilots` | `Autopilots#list` |
|
|
231
|
+
| GET | `/api/v1/autopilots/{id}` | `Autopilots#get_autopilot` |
|
|
232
|
+
| POST | `/api/v1/autopilots` | `Autopilots#create` |
|
|
233
|
+
| PATCH | `/api/v1/autopilots/{id}` | `Autopilots#update` |
|
|
234
|
+
| DELETE | `/api/v1/autopilots/{id}` | `Autopilots#delete` |
|
|
235
|
+
| POST | `/api/v1/autopilots/{id}/activate` | `Autopilots#activate` |
|
|
236
|
+
| POST | `/api/v1/autopilots/{id}/pause` | `Autopilots#pause` |
|
|
237
|
+
| POST | `/api/v1/autopilots/{id}/deactivate` | `Autopilots#deactivate` |
|
|
238
|
+
| POST | `/api/v1/autopilots/{id}/trigger_run` | `Autopilots#trigger_run` |
|
|
239
|
+
| GET | `/api/v1/autopilots/{id}/runs` | `Autopilots#runs` |
|
|
240
|
+
|
|
241
|
+
`openrouter_api_key` is never returned — it is encrypted at rest, and `show`
|
|
242
|
+
reports only `api_key_configured`. `update` carries the same **credential
|
|
243
|
+
redaction guard** as email servers: a bullet-masked key is stripped from the
|
|
244
|
+
payload so a fetch-modify-save cannot destroy the stored credential.
|
|
245
|
+
|
|
246
|
+
`activate` requires an active source, an API key, and a model; it returns 422
|
|
247
|
+
naming the missing prerequisites. `trigger_run` returns 202 — generation is
|
|
248
|
+
asynchronous, so poll `runs`.
|
|
249
|
+
|
|
250
|
+
### Migration / export — 20/20
|
|
251
|
+
|
|
252
|
+
Admin tokens only. `broadcast_channel_id` is required on every call; set it once
|
|
253
|
+
on the client and the gem attaches it.
|
|
254
|
+
|
|
255
|
+
| Method | Path | Gem |
|
|
256
|
+
|---|---|---|
|
|
257
|
+
| GET | `/api/migration/v1/manifest` | `Migration#manifest` |
|
|
258
|
+
| GET | `/api/migration/v1/file_assets/{id}/download` | `Migration#download_file_asset` — raw bytes |
|
|
259
|
+
| GET | `/api/migration/v1/channels` | *(generated)* `Migration#channels` |
|
|
260
|
+
| GET | `/api/migration/v1/subscribers` | *(generated)* `Migration#subscribers` |
|
|
261
|
+
| GET | `/api/migration/v1/templates` | *(generated)* `Migration#templates` |
|
|
262
|
+
| GET | `/api/migration/v1/segments` | *(generated)* `Migration#segments` |
|
|
263
|
+
| GET | `/api/migration/v1/sequences` | *(generated)* `Migration#sequences` |
|
|
264
|
+
| GET | `/api/migration/v1/email_servers` | *(generated)* `Migration#email_servers` |
|
|
265
|
+
| GET | `/api/migration/v1/opt_in_forms` | *(generated)* `Migration#opt_in_forms` |
|
|
266
|
+
| GET | `/api/migration/v1/broadcasts` | *(generated)* `Migration#broadcasts` |
|
|
267
|
+
| GET | `/api/migration/v1/outbound_receipts` | *(generated)* `Migration#outbound_receipts` |
|
|
268
|
+
| GET | `/api/migration/v1/webhook_endpoints` | *(generated)* `Migration#webhook_endpoints` |
|
|
269
|
+
| GET | `/api/migration/v1/tokens` | *(generated)* `Migration#tokens` |
|
|
270
|
+
| GET | `/api/migration/v1/suppressions` | *(generated)* `Migration#suppressions` |
|
|
271
|
+
| GET | `/api/migration/v1/tags` | *(generated)* `Migration#tags` |
|
|
272
|
+
| GET | `/api/migration/v1/users` | *(generated)* `Migration#users` |
|
|
273
|
+
| GET | `/api/migration/v1/link_redirects` | *(generated)* `Migration#link_redirects` |
|
|
274
|
+
| GET | `/api/migration/v1/link_clicks` | *(generated)* `Migration#link_clicks` |
|
|
275
|
+
| GET | `/api/migration/v1/subscriber_histories` | *(generated)* `Migration#subscriber_histories` |
|
|
276
|
+
| GET | `/api/migration/v1/file_assets` | *(generated)* `Migration#file_assets` |
|
|
277
|
+
|
|
278
|
+
`Migration#each_record(:collection)` pages any of them automatically, advancing
|
|
279
|
+
by the page size the **server granted** rather than the one requested (the
|
|
280
|
+
server clamps `limit` to 250).
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Transport coverage
|
|
285
|
+
|
|
286
|
+
Endpoint coverage is the easy half. These are the protocol behaviours the API
|
|
287
|
+
expects a well-behaved client to implement — and where a client can look
|
|
288
|
+
complete while quietly losing information.
|
|
289
|
+
|
|
290
|
+
| Capability | Status | Gem surface |
|
|
291
|
+
|---|---|---|
|
|
292
|
+
| Bearer auth | ✅ | `api_token:` / `BROADCAST_API_TOKEN` |
|
|
293
|
+
| Explicit host, no default | ✅ | `host:` / `BROADCAST_HOST`, required |
|
|
294
|
+
| `User-Agent` attribution | ✅ | `broadcast-ruby/<version>` |
|
|
295
|
+
| **`warnings` array** | ✅ | `result.warnings`, `warnings_mode:` `:log`/`:raise`/`:ignore` |
|
|
296
|
+
| **`Idempotency-Key`** | ✅ | `transactionals.create(idempotency_key:)` |
|
|
297
|
+
| **`Idempotency-Replayed`** | ✅ | `result.idempotent_replay?` |
|
|
298
|
+
| **Rate-limit headers** | ✅ | `result.rate_limit` — limit, remaining, reset |
|
|
299
|
+
| **429 + `Retry-After`** | ✅ | Retried, bounded by `max_retry_delay` |
|
|
300
|
+
| Retry on 5xx and timeout | ✅ | `retry_attempts`, `retry_delay` |
|
|
301
|
+
| Typed errors | ✅ | 401/403/404/409/422/429/5xx each distinct |
|
|
302
|
+
| Redirect handling | ✅ | Same-host GET follows; writes and cross-host refuse |
|
|
303
|
+
| Raw/text responses | ✅ | `client.skill`, `download_file_asset` |
|
|
304
|
+
| Channel scoping | ✅ | `broadcast_channel_id:`, `with_channel(id) { }` |
|
|
305
|
+
| Debug logging | ✅ | Credentials redacted |
|
|
306
|
+
| Pagination helper | ⚠️ Migration only | `each_record`; v1 endpoints page manually |
|
|
307
|
+
| ActionMailer integration | ✅ | `:broadcast` delivery method + Railtie |
|
|
308
|
+
|
|
309
|
+
Error mapping in full:
|
|
310
|
+
|
|
311
|
+
| HTTP | Exception |
|
|
312
|
+
|---|---|
|
|
313
|
+
| 401 | `Broadcast::AuthenticationError` |
|
|
314
|
+
| 403 | `Broadcast::AuthorizationError` |
|
|
315
|
+
| 404 | `Broadcast::NotFoundError` |
|
|
316
|
+
| 409 | `Broadcast::ConflictError` — idempotency replay in flight |
|
|
317
|
+
| 422 | `Broadcast::ValidationError` |
|
|
318
|
+
| 429 | `Broadcast::RateLimitError` — carries `#retry_after` |
|
|
319
|
+
| 5xx | `Broadcast::APIError` |
|
|
320
|
+
| — | `Broadcast::TimeoutError`, `ConfigurationError`, `WarningError`, `DeliveryError` |
|
|
321
|
+
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## Webhook coverage
|
|
325
|
+
|
|
326
|
+
Inbound webhooks — the app calling you.
|
|
327
|
+
|
|
328
|
+
| Capability | Status |
|
|
329
|
+
|---|---|
|
|
330
|
+
| HMAC-SHA256 verification (`v1,<base64>`) | ✅ `Webhook.verify` |
|
|
331
|
+
| 5-minute timestamp tolerance | ✅ Replay protection |
|
|
332
|
+
| Constant-time comparison | ✅ `OpenSSL.fixed_length_secure_compare` |
|
|
333
|
+
| Event type constants | ✅ `Webhook::EVENT_TYPES` — 32 values |
|
|
334
|
+
|
|
335
|
+
Categories: `EMAIL_EVENTS` (8), `SUBSCRIBER_EVENTS` (7), `BROADCAST_EVENTS` (8),
|
|
336
|
+
`SEQUENCE_EVENTS` (7), `SYSTEM_EVENTS` (2).
|
|
337
|
+
|
|
338
|
+
Not covered: payload deserialisation into typed objects. You get the parsed
|
|
339
|
+
JSON body and dispatch on `type` yourself.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Not in the API at all
|
|
344
|
+
|
|
345
|
+
These are Broadcast features with no v1 endpoints. **No client can reach them** —
|
|
346
|
+
this is an API gap, not a gem gap, and nothing here is actionable in this repo.
|
|
347
|
+
|
|
348
|
+
| Feature | Where it lives | Notes |
|
|
349
|
+
|---|---|---|
|
|
350
|
+
| **Ask AI** | Web UI | Conversations, messages, tool-call approval |
|
|
351
|
+
| **Subscriber imports** | Web UI | CSV/TSV upload, mapping, processing |
|
|
352
|
+
| **CSV exports** | Web UI | Subscribers, broadcasts, activity. `GET /subscribers/export` is a web route |
|
|
353
|
+
| **Bulk operations** | Web UI | Subscriber, template, and file-asset bulk actions; broadcast bulk archive |
|
|
354
|
+
| **Channel management** | Web UI | Creating channels, domain/sender setup, onboarding |
|
|
355
|
+
| **User & permission management** | Web UI | Users, system permissions, per-channel permissions |
|
|
356
|
+
| **Settings** | Web UI | General, AI, DNS checks, ESP integrations, sender details |
|
|
357
|
+
| **Global suppressions** | Web UI + migration export | Read-only via `migration.suppressions`; no v1 CRUD |
|
|
358
|
+
| **File assets** | Web UI + migration export | Read-only via migration; no v1 upload |
|
|
359
|
+
| **Link tracking config** | Web UI | Click data readable via `statistics_links` and migration |
|
|
360
|
+
| **Admin / platform** | Web UI | Admin API tokens, SaaS platform admin |
|
|
361
|
+
| **Confirmation templates & pages** | Partly API | Configurable as template attributes; no dedicated endpoints |
|
|
362
|
+
|
|
363
|
+
### Autopilot: this section used to be wrong, and how
|
|
364
|
+
|
|
365
|
+
Autopilot was listed here as web-UI-only. The reasoning was that `Token`
|
|
366
|
+
defined `permission_to_autopilot_read` / `_write` while no `api/v1/autopilots`
|
|
367
|
+
controller existed — inert flags. That was true when written, and the entry
|
|
368
|
+
predicted it would be the first thing to go stale. It was.
|
|
369
|
+
|
|
370
|
+
Autopilot **is** in the API as of app v2.20.0 and is covered
|
|
371
|
+
[above](#autopilot--1010). The build also closed the permission gap the flags
|
|
372
|
+
had exposed: `user_broadcast_channel_permissions` had no autopilot columns, so
|
|
373
|
+
the feature was silently sudo-only in the UI and its policy scopes raised
|
|
374
|
+
`NoMethodError` for any user holding a channel-permission record. Migration
|
|
375
|
+
`20260726180000` added them; `UserManagementPolicy` now derives assignable
|
|
376
|
+
attributes from the schema rather than a hand-written list.
|
|
377
|
+
|
|
378
|
+
Sub-resources are still web-only: autopilot **sources** and **tone samples**
|
|
379
|
+
have no v1 endpoints. `create`/`update` set the generator itself, and
|
|
380
|
+
`activate` requires an active source — so a source must exist before the API
|
|
381
|
+
can activate anything. That is the next gap in this resource.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## Demo instances answer differently
|
|
386
|
+
|
|
387
|
+
Nothing here is a gem concern, but a client pointed at a demo instance
|
|
388
|
+
(`DEMO_MODE` set) sees behaviour no error class distinguishes:
|
|
389
|
+
|
|
390
|
+
- **The migration API is off entirely.** Every `/api/migration/v1` route
|
|
391
|
+
returns `403` with `{"error": "The migration API is disabled in demo mode."}`,
|
|
392
|
+
raised *before* token verification — so a valid and an invalid admin token
|
|
393
|
+
get identical answers, deliberately, to stop the demo being used as a token
|
|
394
|
+
oracle. In the gem this surfaces as `Broadcast::AuthorizationError`.
|
|
395
|
+
- **Transactional sends are accepted but never delivered.** The request
|
|
396
|
+
succeeds and a record is created; the mailer returns early. `2xx` does not
|
|
397
|
+
mean an email left the building.
|
|
398
|
+
- **`/api/v1` itself is unguarded.** It honours any valid token — but tokens
|
|
399
|
+
cannot be minted in demo mode, so in practice the only usable credential is
|
|
400
|
+
the "Default transactional email" token each channel auto-provisions.
|
|
401
|
+
|
|
402
|
+
---
|
|
403
|
+
|
|
404
|
+
## Limits of the 100%
|
|
405
|
+
|
|
406
|
+
The figure means **every operation the router serves has a corresponding gem
|
|
407
|
+
method**. It does not mean the gem is complete. Specifically, it does not measure:
|
|
408
|
+
|
|
409
|
+
**Parameter completeness.** This is the important one. Resource methods take
|
|
410
|
+
`**attrs` and forward them, so a method covers its endpoint regardless of which
|
|
411
|
+
attributes it knows about. The v2.19 additions — template `confirmation_page_settings`,
|
|
412
|
+
opt-in-form `welcome_email_template_id`, admin-only `confirmed_at` on subscriber
|
|
413
|
+
create — all "passed through" at 100% coverage while being entirely undocumented.
|
|
414
|
+
They are documented now, but the metric would not have caught them and will not
|
|
415
|
+
catch the next ones. Closing this needs response schemas in `openapi/overlay.yml`
|
|
416
|
+
and a parameter-level check.
|
|
417
|
+
|
|
418
|
+
**Response shapes.** The gem returns parsed JSON. It does not model or validate
|
|
419
|
+
response bodies, so an API field that changes type is invisible to it.
|
|
420
|
+
|
|
421
|
+
**Semantics.** Coverage says a method issues the right request, not that it
|
|
422
|
+
handles the endpoint's behaviour correctly. `list` returning only main opt-in
|
|
423
|
+
forms, or `get_segment` recounting on read, are documented in comments — not
|
|
424
|
+
enforced by anything.
|
|
425
|
+
|
|
426
|
+
**Live correctness.** The suite mocks HTTP. `rake test_live` exercises a real
|
|
427
|
+
instance and is **not** part of the coverage figure; it needs a token and host.
|
|
428
|
+
|
|
429
|
+
### Known unevenness
|
|
430
|
+
|
|
431
|
+
- **Pagination.** `Migration#each_record` auto-pages. The v1 endpoints do not
|
|
432
|
+
have an equivalent — callers loop on `page:` themselves.
|
|
433
|
+
- **Naming.** Getters are inconsistent: `get_broadcast`, `get_segment`,
|
|
434
|
+
`get_endpoint`, `get_opt_in_form`, `get_email_server`, `get_template`,
|
|
435
|
+
`get_transactional`, `get_step`. `Broadcast` collides with the top-level
|
|
436
|
+
module name, which is what forced the prefix originally; the rest followed for
|
|
437
|
+
symmetry. Worth settling before other SDKs copy the pattern.
|
|
438
|
+
- **Steps live on `Sequences`.** There is no `client.sequence_steps`; step
|
|
439
|
+
methods hang off the sequences resource, matching the nested routes.
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Keeping this current
|
|
444
|
+
|
|
445
|
+
This file is written by hand from generated data. Regenerate the underlying
|
|
446
|
+
numbers with:
|
|
447
|
+
|
|
448
|
+
```bash
|
|
449
|
+
# in the broadcast repo
|
|
450
|
+
bin/rails openapi:generate
|
|
451
|
+
bin/rails "openapi:coverage[../broadcast-ruby]"
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`openapi:check` now runs in CI, so a changed API surface fails the build rather
|
|
455
|
+
than silently ageing this file. Note what that guard does **not** cover: it
|
|
456
|
+
compares the spec to the router, not this document to the spec. Both stale
|
|
457
|
+
entries found in the 2.20.0 pass — the version header, and Autopilot appearing
|
|
458
|
+
as both covered and absent — would have survived a green CI run indefinitely.
|
|
459
|
+
|
|
460
|
+
So on every API bump, re-check the three hand-maintained claims by hand:
|
|
461
|
+
|
|
462
|
+
1. **The version header** at the top — it does not come from anywhere.
|
|
463
|
+
2. **[Not in the API at all](#not-in-the-api-at-all)** — a new endpoint means a
|
|
464
|
+
row leaves that table. This is where the contradiction hid.
|
|
465
|
+
3. **Prose in the endpoint map** — counts regenerate, the sentences under them
|
|
466
|
+
do not.
|
|
467
|
+
|
|
468
|
+
Cross-language plans live in [SDK-TODO.md](SDK-TODO.md); the v0.3.0 work log is
|
|
469
|
+
in [TODO.md](TODO.md).
|