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.
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).
@@ -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
- uri = build_uri(path, method, payload)
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