broadcast-ruby 0.1.4 → 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,29 +1,65 @@
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
5
+ CHANNEL_OVERRIDE_KEY = :__broadcast_ruby_channel_override
6
+
9
7
  attr_reader :config
10
8
 
11
9
  def initialize(**settings)
12
10
  @config = Configuration.new
13
11
  settings.each { |k, v| @config.public_send(:"#{k}=", v) }
14
12
  @config.validate!
13
+ @connection = Connection.new(@config)
14
+ end
15
+
16
+ # --- Channel scoping (admin/system tokens) ---
17
+
18
+ # Run a block with a temporary broadcast_channel_id override that will be
19
+ # auto-included on every request inside the block. Useful for admin/system
20
+ # tokens that need to scope each call to a specific channel.
21
+ #
22
+ # client.with_channel(123) do
23
+ # client.email_servers.list
24
+ # end
25
+ def with_channel(broadcast_channel_id)
26
+ key = channel_override_key
27
+ previous = Thread.current[key]
28
+ Thread.current[key] = broadcast_channel_id
29
+ yield self
30
+ ensure
31
+ Thread.current[key] = previous
15
32
  end
16
33
 
17
- # --- Transactional email ---
34
+ # --- Transactional email (convenience shims) ---
18
35
 
19
- def send_email(to:, subject:, body:, reply_to: nil)
20
- payload = { to: to, subject: subject, body: body }
21
- payload[:reply_to] = reply_to if reply_to
22
- request(:post, '/api/v1/transactionals.json', payload)
36
+ # Thin convenience wrapper around `transactionals.create`. Use
37
+ # `client.transactionals.create` directly for template_id, double_opt_in,
38
+ # preheader, idempotency_key, and other advanced options.
39
+ def send_email(to:, subject: nil, body: nil, reply_to: nil)
40
+ transactionals.create(to: to, subject: subject, body: body, reply_to: reply_to)
23
41
  end
24
42
 
25
43
  def get_email(id)
26
- request(:get, "/api/v1/transactionals/#{id}.json")
44
+ transactionals.get_transactional(id)
45
+ end
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
27
63
  end
28
64
 
29
65
  # --- Resource sub-clients ---
@@ -52,124 +88,59 @@ module Broadcast
52
88
  @webhook_endpoints ||= Resources::WebhookEndpoints.new(self)
53
89
  end
54
90
 
55
- # @api private
56
- def request(method, path, body_or_params = nil)
57
- uri = URI("#{@config.host}#{path}")
91
+ def transactionals
92
+ @transactionals ||= Resources::Transactionals.new(self)
93
+ end
58
94
 
59
- if method == :get && body_or_params.is_a?(Hash) && body_or_params.any?
60
- uri.query = URI.encode_www_form(flatten_params(body_or_params))
61
- end
95
+ def opt_in_forms
96
+ @opt_in_forms ||= Resources::OptInForms.new(self)
97
+ end
62
98
 
63
- retry_with_backoff do
64
- http = Net::HTTP.new(uri.host, uri.port)
65
- http.use_ssl = uri.scheme == 'https'
66
- http.open_timeout = @config.open_timeout
67
- http.read_timeout = @config.timeout
99
+ def email_servers
100
+ @email_servers ||= Resources::EmailServers.new(self)
101
+ end
68
102
 
69
- req = build_request(method, uri)
70
- req.body = body_or_params.to_json if method != :get && body_or_params.is_a?(Hash) && body_or_params.any?
103
+ def autopilots
104
+ @autopilots ||= Resources::Autopilots.new(self)
105
+ end
71
106
 
72
- log_request(req, method == :get ? nil : body_or_params) if @config.debug
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
73
116
 
74
- response = http.request(req)
75
- log_response(response) if @config.debug
76
- handle_response(response)
77
- end
78
- rescue Net::OpenTimeout, Net::ReadTimeout => e
79
- raise Broadcast::TimeoutError, "Request timeout: #{e.message}"
117
+ # @api private
118
+ def request(method, path, body_or_params = nil, headers: {}, raw: false)
119
+ payload = inject_channel_scope(body_or_params)
120
+ @connection.request(method, path, payload, headers: headers, raw: raw)
80
121
  end
81
122
 
82
123
  private
83
124
 
84
- def build_request(method, uri)
85
- klass = case method
86
- when :get then Net::HTTP::Get
87
- when :post then Net::HTTP::Post
88
- when :patch then Net::HTTP::Patch
89
- when :delete then Net::HTTP::Delete
90
- else raise ArgumentError, "Unsupported HTTP method: #{method}"
91
- end
92
-
93
- req = klass.new(uri)
94
- req['Authorization'] = "Bearer #{@config.api_token}"
95
- req['Content-Type'] = 'application/json'
96
- req['User-Agent'] = "broadcast-ruby/#{Broadcast::VERSION}"
97
- req
98
- end
99
-
100
- def handle_response(response)
101
- case response.code.to_i
102
- when 200, 201
103
- return {} if response.body.nil? || response.body.strip.empty?
104
-
105
- JSON.parse(response.body)
106
- when 401
107
- raise AuthenticationError, parse_error(response) || 'Authentication failed'
108
- when 404
109
- raise NotFoundError, parse_error(response) || 'Resource not found'
110
- when 422
111
- raise ValidationError, parse_error(response) || 'Validation failed'
112
- when 429
113
- raise RateLimitError, parse_error(response) || 'Rate limit exceeded'
114
- when 500, 502, 503, 504
115
- raise APIError, parse_error(response) || "Server error (#{response.code})"
116
- else
117
- raise APIError, parse_error(response) || "Unexpected response: #{response.code}"
118
- end
119
- end
120
-
121
- def parse_error(response)
122
- JSON.parse(response.body)['error']
123
- rescue JSON::ParserError
124
- nil
125
- end
126
-
127
- def retry_with_backoff
128
- attempts = 0
129
- begin
130
- attempts += 1
131
- yield
132
- rescue Net::OpenTimeout, Net::ReadTimeout
133
- raise if attempts >= @config.retry_attempts
134
-
135
- sleep(@config.retry_delay * attempts)
136
- retry
137
- rescue APIError => e
138
- raise unless attempts < @config.retry_attempts && e.message.include?('Server error')
139
-
140
- sleep(@config.retry_delay * attempts)
141
- retry
142
- end
143
- end
144
-
145
- def flatten_params(params)
146
- result = []
147
- params.each do |key, value|
148
- case value
149
- when Array
150
- value.each { |v| result << ["#{key}[]", v.to_s] }
151
- when Hash
152
- value.each { |k, v| result << ["#{key}[#{k}]", v.to_s] }
153
- when nil
154
- next
155
- else
156
- result << [key.to_s, value.to_s]
157
- end
158
- end
159
- result
160
- end
161
-
162
- def log_request(request, body)
163
- return unless @config.logger
164
-
165
- @config.logger.debug("[Broadcast] #{request.method} #{request.uri}")
166
- @config.logger.debug("[Broadcast] Body: #{body.to_json}") if body.is_a?(Hash) && body.any?
167
- end
168
-
169
- def log_response(response)
170
- return unless @config.logger
171
-
172
- @config.logger.debug("[Broadcast] Response: #{response.code} #{response.body}")
125
+ def channel_override_key
126
+ :"#{CHANNEL_OVERRIDE_KEY}_#{object_id}"
127
+ end
128
+
129
+ def active_channel_id
130
+ Thread.current[channel_override_key] || @config.broadcast_channel_id
131
+ end
132
+
133
+ # Auto-include broadcast_channel_id in request payload when configured (or
134
+ # set via with_channel) and not already specified by the caller.
135
+ def inject_channel_scope(body_or_params)
136
+ channel_id = active_channel_id
137
+ return body_or_params if channel_id.nil?
138
+
139
+ payload = body_or_params.is_a?(Hash) ? body_or_params.dup : {}
140
+ return payload if payload[:broadcast_channel_id] || payload['broadcast_channel_id']
141
+
142
+ payload[:broadcast_channel_id] = channel_id
143
+ payload
173
144
  end
174
145
  end
175
146
  end