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.
@@ -2,32 +2,81 @@
2
2
 
3
3
  module Broadcast
4
4
  class Configuration
5
+ # How to handle the `warnings` array the API returns on successful writes
6
+ # (docs: api-response-warnings):
7
+ # :log — warn through `logger` if one is set (default)
8
+ # :raise — raise Broadcast::WarningError; note the write already happened
9
+ # :ignore — leave them on the response for the caller to inspect
10
+ WARNINGS_MODES = %i[log raise ignore].freeze
11
+
12
+ # Env vars use the same names as the Broadcast CLI's ~/.config/broadcast/config,
13
+ # so a machine set up for the CLI can drive the gem with no extra config.
14
+ ENV_HOST = 'BROADCAST_HOST'
15
+ ENV_TOKEN = 'BROADCAST_API_TOKEN'
16
+
5
17
  attr_accessor :api_token,
6
18
  :host,
7
19
  :timeout,
8
20
  :open_timeout,
9
21
  :retry_attempts,
10
22
  :retry_delay,
23
+ :max_retry_delay,
24
+ :warnings_mode,
11
25
  :logger,
12
26
  :debug,
13
27
  :broadcast_channel_id
14
28
 
15
29
  def initialize
16
- @api_token = nil
17
- @host = 'https://sendbroadcast.com'
30
+ @api_token = ENV.fetch(ENV_TOKEN, nil)
31
+ # No default host. Broadcast is self-hosted-first — every instance lives
32
+ # at its own domain, so any built-in guess is wrong for nearly everyone.
33
+ @host = ENV.fetch(ENV_HOST, nil)
18
34
  @timeout = 30
19
35
  @open_timeout = 10
20
36
  @retry_attempts = 3
21
37
  @retry_delay = 1
38
+ # Ceiling for a server-supplied Retry-After. Without it a long rate-limit
39
+ # window would block the caller for as long as the server asked.
40
+ @max_retry_delay = 30
41
+ @warnings_mode = :log
22
42
  @logger = nil
23
43
  @debug = false
24
44
  @broadcast_channel_id = nil
25
45
  end
26
46
 
27
47
  def validate!
28
- raise ConfigurationError, 'api_token is required' if api_token.nil? || api_token.to_s.strip.empty?
48
+ raise ConfigurationError, 'api_token is required' if blank?(api_token)
49
+ raise ConfigurationError, host_missing_message if blank?(host)
50
+
51
+ self.host = host.to_s.strip.chomp('/')
52
+ validate_host_scheme!
53
+ validate_warnings_mode!
54
+ end
55
+
56
+ private
57
+
58
+ def blank?(value)
59
+ value.nil? || value.to_s.strip.empty?
60
+ end
61
+
62
+ def host_missing_message
63
+ "host is required — point it at your Broadcast instance, e.g. \
64
+ Broadcast::Client.new(api_token: '...', host: 'https://mail.example.com'). \
65
+ You can also set the #{ENV_HOST} environment variable."
66
+ end
67
+
68
+ def validate_host_scheme!
69
+ return if host.start_with?('http://', 'https://')
70
+
71
+ raise ConfigurationError, "host must include a scheme (http:// or https://), got #{host.inspect}"
72
+ end
73
+
74
+ def validate_warnings_mode!
75
+ self.warnings_mode = warnings_mode.to_sym
76
+ return if WARNINGS_MODES.include?(warnings_mode)
29
77
 
30
- self.host = host.chomp('/') if host&.end_with?('/')
78
+ raise ConfigurationError,
79
+ "warnings_mode must be one of #{WARNINGS_MODES.join(', ')}, got #{warnings_mode.inspect}"
31
80
  end
32
81
  end
33
82
  end
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Broadcast
8
+ # HTTP transport. Owns request building, response/error mapping, retries,
9
+ # redirects, and debug logging. Split out of Client so Client stays a thin
10
+ # facade over configuration and resource sub-clients.
11
+ class Connection
12
+ MAX_REDIRECTS = 3
13
+ REDIRECT_CODES = [301, 302, 307, 308].freeze
14
+
15
+ ERROR_MAPPING = {
16
+ 401 => [AuthenticationError, 'Authentication failed'],
17
+ 403 => [AuthorizationError, 'Not authorized'],
18
+ 404 => [NotFoundError, 'Resource not found'],
19
+ 409 => [ConflictError, 'A request with this Idempotency-Key is still being processed'],
20
+ 422 => [ValidationError, 'Validation failed']
21
+ }.freeze
22
+ private_constant :ERROR_MAPPING
23
+
24
+ def initialize(config)
25
+ @config = config
26
+ @debug_logger = DebugLogger.new(config)
27
+ end
28
+
29
+ # @param raw [Boolean] return the response body as a String instead of
30
+ # parsing it as JSON — for text/plain endpoints such as /api/v1/skill.
31
+ def request(method, path, payload = nil, headers: {}, raw: false)
32
+ uri = build_uri(path, method, payload)
33
+ context = { headers: headers, raw: raw, redirects: 0 }
34
+
35
+ retry_with_backoff { execute(method, uri, payload, context) }
36
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
37
+ raise Broadcast::TimeoutError, "Request timeout: #{e.message}"
38
+ end
39
+
40
+ private
41
+
42
+ def build_uri(path, method, payload)
43
+ uri = URI("#{@config.host}#{path}")
44
+ uri.query = URI.encode_www_form(flatten_params(payload)) if method == :get && payload_present?(payload)
45
+ uri
46
+ end
47
+
48
+ def execute(method, uri, payload, context)
49
+ http = Net::HTTP.new(uri.host, uri.port)
50
+ http.use_ssl = uri.scheme == 'https'
51
+ http.open_timeout = @config.open_timeout
52
+ http.read_timeout = @config.timeout
53
+
54
+ req = build_request(method, uri, context[:headers])
55
+ req.body = payload.to_json if method != :get && payload_present?(payload)
56
+
57
+ @debug_logger.request(req, method == :get ? nil : payload)
58
+ response = http.request(req)
59
+ @debug_logger.response(response)
60
+
61
+ return follow_redirect(response, method, uri, context) if redirect?(response)
62
+
63
+ handle_response(response, raw: context[:raw])
64
+ end
65
+
66
+ # --- Redirects -----------------------------------------------------------
67
+ #
68
+ # A redirect nearly always means a misconfigured `host` (http vs https, a
69
+ # bare apex that redirects to www, a stale domain). Two things are never
70
+ # followed:
71
+ #
72
+ # - writes, because replaying a send against an unexpected origin is worse
73
+ # than failing; and
74
+ # - anything that changes host, because every request carries
75
+ # `Authorization: Bearer <token>` and following would hand the API token
76
+ # to whatever the redirect points at.
77
+ #
78
+ # Both raise with the destination named, so a misconfigured host diagnoses
79
+ # itself instead of failing mysteriously.
80
+
81
+ def redirect?(response)
82
+ REDIRECT_CODES.include?(response.code.to_i)
83
+ end
84
+
85
+ def follow_redirect(response, method, uri, context)
86
+ location = response['location']
87
+ redirects = context[:redirects]
88
+
89
+ if method != :get
90
+ raise APIError,
91
+ "Host redirected #{method.to_s.upcase} #{uri} to #{location || '(no Location header)'}. " \
92
+ 'Set `host` to the final URL — writes are not followed automatically.'
93
+ end
94
+
95
+ raise APIError, "Redirect from #{uri} had no Location header" if location.nil?
96
+ raise APIError, "Too many redirects (#{MAX_REDIRECTS}) starting at #{uri}" if redirects >= MAX_REDIRECTS
97
+
98
+ target = URI.join(uri, location)
99
+ raise APIError, cross_host_redirect_message(uri, target) unless same_host?(uri, target)
100
+
101
+ # Query string is already baked into the current URI; don't re-append it.
102
+ execute(:get, target, nil, context.merge(redirects: redirects + 1))
103
+ end
104
+
105
+ def same_host?(from, to)
106
+ from.host&.downcase == to.host&.downcase
107
+ end
108
+
109
+ def cross_host_redirect_message(from, target)
110
+ "Host redirected #{from} to a different host (#{target}). Not following it — " \
111
+ 'the request carries your API token. Set `host` to the correct instance URL.'
112
+ end
113
+
114
+ # --- Requests ------------------------------------------------------------
115
+
116
+ def build_request(method, uri, extra_headers)
117
+ klass = case method
118
+ when :get then Net::HTTP::Get
119
+ when :post then Net::HTTP::Post
120
+ when :patch then Net::HTTP::Patch
121
+ when :delete then Net::HTTP::Delete
122
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
123
+ end
124
+
125
+ req = klass.new(uri)
126
+ req['Authorization'] = "Bearer #{@config.api_token}"
127
+ req['Content-Type'] = 'application/json'
128
+ req['User-Agent'] = "broadcast-ruby/#{Broadcast::VERSION}"
129
+ extra_headers.each { |key, value| req[key.to_s] = value.to_s unless value.nil? }
130
+ req
131
+ end
132
+
133
+ def payload_present?(payload)
134
+ payload.is_a?(Hash) && payload.any?
135
+ end
136
+
137
+ # --- Responses -----------------------------------------------------------
138
+
139
+ def handle_response(response, raw:)
140
+ code = response.code.to_i
141
+ return build_success(response, raw: raw) if code.between?(200, 299)
142
+
143
+ raise_rate_limit_error(response) if code == 429
144
+
145
+ if (mapping = ERROR_MAPPING[code])
146
+ klass, default = mapping
147
+ raise klass, parse_error(response) || default
148
+ end
149
+
150
+ raise APIError, parse_error(response) || "Server error (#{code})" if code >= 500
151
+
152
+ raise APIError, parse_error(response) || "Unexpected response: #{code}"
153
+ end
154
+
155
+ def build_success(response, raw:)
156
+ return raw_body(response) if raw
157
+
158
+ result = Response.build(
159
+ parse_success_body(response),
160
+ status: response.code.to_i,
161
+ headers: extract_headers(response)
162
+ )
163
+ handle_warnings(result)
164
+ result
165
+ end
166
+
167
+ # Raw endpoints serve two very different things: text (/api/v1/skill) and
168
+ # binary file assets. Trusting the body's default encoding would tag PNG
169
+ # bytes as UTF-8 and blow up on the first regex match, so only keep a text
170
+ # encoding when the server actually declared a charset.
171
+ def raw_body(response)
172
+ body = response.body.to_s
173
+ return body if response.type_params['charset']
174
+
175
+ body.dup.force_encoding(Encoding::BINARY)
176
+ end
177
+
178
+ def raise_rate_limit_error(response)
179
+ retry_after = response['retry-after']&.to_i
180
+ raise RateLimitError.new(parse_error(response) || 'Rate limit exceeded', retry_after: retry_after)
181
+ end
182
+
183
+ def extract_headers(response)
184
+ headers = {}
185
+ response.each_header { |key, value| headers[key.downcase] = value }
186
+ headers
187
+ end
188
+
189
+ def parse_success_body(response)
190
+ return {} if response.body.nil? || response.body.strip.empty?
191
+
192
+ JSON.parse(response.body)
193
+ rescue JSON::ParserError
194
+ # A 2xx that isn't JSON (an HTML error page from a proxy, say). Surface it
195
+ # as an empty body rather than exploding — `raw: true` is the way to read
196
+ # non-JSON endpoints deliberately.
197
+ {}
198
+ end
199
+
200
+ def parse_error(response)
201
+ body = JSON.parse(response.body)
202
+ body['error'] || format_errors(body['errors'])
203
+ rescue JSON::ParserError, TypeError
204
+ nil
205
+ end
206
+
207
+ # ActiveModel errors arrive as {"field" => ["msg", ...]}
208
+ def format_errors(errors)
209
+ return nil if errors.nil?
210
+ return errors.join(', ') if errors.is_a?(Array)
211
+ return nil unless errors.is_a?(Hash)
212
+
213
+ errors.map { |field, messages| "#{field} #{Array(messages).join(', ')}" }.join('; ')
214
+ end
215
+
216
+ # --- Warnings ------------------------------------------------------------
217
+
218
+ def handle_warnings(result)
219
+ return unless result.is_a?(Response) && result.warnings?
220
+
221
+ case @config.warnings_mode
222
+ when :raise then raise WarningError.new(result.warnings, result)
223
+ when :log then @debug_logger.warnings(result.warnings)
224
+ end
225
+ end
226
+
227
+ # --- Retries -------------------------------------------------------------
228
+
229
+ def retry_with_backoff
230
+ attempts = 0
231
+ begin
232
+ attempts += 1
233
+ yield
234
+ rescue Net::OpenTimeout, Net::ReadTimeout
235
+ raise if attempts >= @config.retry_attempts
236
+
237
+ sleep(@config.retry_delay * attempts)
238
+ retry
239
+ rescue RateLimitError => e
240
+ raise if attempts >= @config.retry_attempts
241
+
242
+ sleep(rate_limit_delay(e, attempts))
243
+ retry
244
+ rescue APIError => e
245
+ raise unless attempts < @config.retry_attempts && e.message.include?('Server error')
246
+
247
+ sleep(@config.retry_delay * attempts)
248
+ retry
249
+ end
250
+ end
251
+
252
+ # Honour Retry-After when the server sent one, but never sleep longer than
253
+ # max_retry_delay — a wide rate-limit window shouldn't hang the caller.
254
+ def rate_limit_delay(error, attempts)
255
+ requested = error.retry_after || (@config.retry_delay * attempts)
256
+ [requested, @config.max_retry_delay].min
257
+ end
258
+
259
+ # --- Params --------------------------------------------------------------
260
+
261
+ def flatten_params(params)
262
+ result = []
263
+ params.each do |key, value|
264
+ case value
265
+ when Array
266
+ value.each { |v| result << ["#{key}[]", v.to_s] }
267
+ when Hash
268
+ value.each { |k, v| result << ["#{key}[#{k}]", v.to_s] }
269
+ when nil
270
+ next
271
+ else
272
+ result << [key.to_s, value.to_s]
273
+ end
274
+ end
275
+ result
276
+ end
277
+ end
278
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Broadcast
6
+ # Debug logging for HTTP traffic, kept separate from Connection so the
7
+ # redaction rules live in one obvious place.
8
+ #
9
+ # Request bodies routinely carry SMTP passwords and provider API keys
10
+ # (email server create/update), so nothing is logged verbatim — matching keys
11
+ # are replaced before the body is serialized.
12
+ class DebugLogger
13
+ SENSITIVE_KEYS = %w[
14
+ smtp_password aws_access_key_id aws_secret_access_key
15
+ outbound_aws_access_key_id outbound_aws_secret_access_key
16
+ postmark_api_token inboxroad_api_token smtp_com_api_key
17
+ api_key api_token password secret
18
+ ].freeze
19
+
20
+ def initialize(config)
21
+ @config = config
22
+ end
23
+
24
+ def enabled?
25
+ @config.debug && !@config.logger.nil?
26
+ end
27
+
28
+ def request(http_request, body)
29
+ return unless enabled?
30
+
31
+ @config.logger.debug("[Broadcast] #{http_request.method} #{http_request.uri}")
32
+ return unless body.is_a?(Hash) && body.any?
33
+
34
+ @config.logger.debug("[Broadcast] Body: #{redact(body).to_json}")
35
+ end
36
+
37
+ def response(http_response)
38
+ return unless enabled?
39
+
40
+ @config.logger.debug("[Broadcast] Response: #{http_response.code} #{http_response.body}")
41
+ end
42
+
43
+ def warnings(warnings)
44
+ return unless @config.logger
45
+
46
+ warnings.each { |warning| @config.logger.warn("[Broadcast] #{warning}") }
47
+ end
48
+
49
+ private
50
+
51
+ def redact(value)
52
+ case value
53
+ when Hash
54
+ value.to_h do |key, nested|
55
+ SENSITIVE_KEYS.include?(key.to_s) ? [key, '[REDACTED]'] : [key, redact(nested)]
56
+ end
57
+ when Array
58
+ value.map { |item| redact(item) }
59
+ else
60
+ value
61
+ end
62
+ end
63
+ end
64
+ end
@@ -13,6 +13,11 @@ module Broadcast
13
13
  body: extract_body(mail),
14
14
  reply_to: mail.reply_to&.first
15
15
  )
16
+ rescue Broadcast::WarningError
17
+ # The send succeeded — warnings_mode: :raise is about surfacing ignored
18
+ # parameters, not reporting a delivery failure. Wrapping it in
19
+ # DeliveryError would tell ActionMailer the mail didn't go out.
20
+ raise
16
21
  rescue Broadcast::Error => e
17
22
  raise DeliveryError, "Failed to deliver email: #{e.message}"
18
23
  end
@@ -13,11 +13,37 @@ module Broadcast
13
13
 
14
14
  class NotFoundError < APIError; end
15
15
 
16
- class RateLimitError < APIError; end
16
+ # 409 an in-flight request is already using this Idempotency-Key. The
17
+ # original request is still processing; retrying after a short pause will
18
+ # either replay its stored response or run fresh if it failed.
19
+ class ConflictError < APIError; end
20
+
21
+ class RateLimitError < APIError
22
+ # Seconds the server asked us to wait, parsed from the Retry-After header.
23
+ attr_reader :retry_after
24
+
25
+ def initialize(message = nil, retry_after: nil)
26
+ super(message)
27
+ @retry_after = retry_after
28
+ end
29
+ end
17
30
 
18
31
  class ValidationError < Error; end
19
32
 
20
33
  class TimeoutError < Error; end
21
34
 
22
35
  class DeliveryError < Error; end
36
+
37
+ # Raised instead of returning when config.warnings_mode is :raise and a 2xx
38
+ # response carried warnings. The request DID succeed — the write happened.
39
+ # Callers rescuing this must not assume anything was rolled back.
40
+ class WarningError < Error
41
+ attr_reader :warnings, :response
42
+
43
+ def initialize(warnings, response = nil)
44
+ @warnings = warnings
45
+ @response = response
46
+ super("API returned #{warnings.size} warning(s): #{warnings.join('; ')}")
47
+ end
48
+ end
23
49
  end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ # Autopilot — AI-generated newsletters.
6
+ #
7
+ # Requires the `autopilot_read` / `autopilot_write` token permissions.
8
+ class Autopilots < Base
9
+ # The API renders a configured key bullet-masked and never returns the
10
+ # real value. Writing a masked value back would replace a working
11
+ # credential with bullets, so it is stripped — same guard as
12
+ # EmailServers#update.
13
+ REDACTED_KEY_PATTERN = /\A•+\z/
14
+
15
+ def list(**params)
16
+ get('/api/v1/autopilots', params)
17
+ end
18
+
19
+ def get_autopilot(id)
20
+ get("/api/v1/autopilots/#{id}")
21
+ end
22
+
23
+ # Create an autopilot. Attrs are wrapped under `autopilot:` on the wire.
24
+ #
25
+ # name: required, unique per channel
26
+ # openrouter_api_key: OpenRouter credential (write-only)
27
+ # ai_model: e.g. 'openai/gpt-4o'
28
+ # schedule_frequency: 'daily' | 'weekly' | 'biweekly' | 'monthly'
29
+ # schedule_day_of_week:, schedule_day_of_month:, schedule_time:, schedule_timezone:
30
+ # copies_to_generate: how many drafts each run produces
31
+ # tone_description:, content_instructions:, newsletter_structure:
32
+ # segment_ids: array — restrict the newsletter's audience
33
+ def create(**attrs)
34
+ post('/api/v1/autopilots', { autopilot: attrs })
35
+ end
36
+
37
+ # Update an autopilot. A bullet-masked openrouter_api_key is dropped
38
+ # before sending; pass the real key to rotate it, or omit the field.
39
+ def update(id, **attrs)
40
+ patch("/api/v1/autopilots/#{id}", { autopilot: scrub_redacted_key(attrs) })
41
+ end
42
+
43
+ def delete(id)
44
+ @client.request(:delete, "/api/v1/autopilots/#{id}")
45
+ end
46
+
47
+ # --- Lifecycle ---
48
+
49
+ # Activate the autopilot so it runs on its schedule.
50
+ #
51
+ # Requires at least one active source, an API key, and a model. Raises
52
+ # Broadcast::ValidationError naming the missing prerequisites otherwise.
53
+ def activate(id)
54
+ post("/api/v1/autopilots/#{id}/activate")
55
+ end
56
+
57
+ def pause(id)
58
+ post("/api/v1/autopilots/#{id}/pause")
59
+ end
60
+
61
+ def deactivate(id)
62
+ post("/api/v1/autopilots/#{id}/deactivate")
63
+ end
64
+
65
+ # Queue a generation run now. Returns 202 with the created run — the work
66
+ # is asynchronous, so poll `runs` for progress rather than expecting a
67
+ # finished newsletter here.
68
+ def trigger_run(id)
69
+ post("/api/v1/autopilots/#{id}/trigger_run")
70
+ end
71
+
72
+ # --- Runs ---
73
+
74
+ # Generation runs, most recent first. Supports limit: and offset:.
75
+ def runs(id, **params)
76
+ get("/api/v1/autopilots/#{id}/runs", params)
77
+ end
78
+
79
+ private
80
+
81
+ def scrub_redacted_key(attrs)
82
+ key = attrs[:openrouter_api_key] || attrs['openrouter_api_key']
83
+ return attrs unless key.is_a?(String) && key.match?(REDACTED_KEY_PATTERN)
84
+
85
+ warn_redacted
86
+ attrs.reject { |name, _| name.to_sym == :openrouter_api_key }
87
+ end
88
+
89
+ def warn_redacted
90
+ msg = '[broadcast-ruby] Dropped redacted openrouter_api_key from update payload — ' \
91
+ 'pass the real key or omit the field'
92
+ if @client.config.logger
93
+ @client.config.logger.warn(msg)
94
+ else
95
+ Kernel.warn(msg)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ # Introspection endpoints. Primarily built for AI agents and CLIs that need
6
+ # to discover what a token can do before acting, but equally useful for
7
+ # health checks and for failing fast on a misconfigured deploy.
8
+ class Discovery < Base
9
+ # Identity of the current token: label, type (channel_scoped or
10
+ # admin_cross_channel), per-resource permissions, and the resolved channel.
11
+ def whoami
12
+ get('/api/v1/whoami')
13
+ end
14
+
15
+ # Channel sender config, subscriber counts, and per-feature transmission
16
+ # readiness. Worth calling before a send — `readiness.broadcasts == false`
17
+ # means the channel has no usable email server or sender identity.
18
+ def status
19
+ get('/api/v1/status')
20
+ end
21
+
22
+ # Full capability manifest: platform version, token permissions, channel
23
+ # status, the endpoint list the token can reach, rate limit, and usage tips.
24
+ def prime
25
+ get('/api/v1/prime')
26
+ end
27
+
28
+ # Plain-text agent skill manifest (Markdown with YAML front matter),
29
+ # including the safety rules agents are expected to follow. Returns a
30
+ # String, not a Hash — this endpoint serves text/plain.
31
+ def skill
32
+ @client.request(:get, '/api/v1/skill', nil, raw: true)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ # Read-only export endpoints under /api/migration/v1 — the surface behind
6
+ # backups and instance-to-instance migration.
7
+ #
8
+ # Two things differ from the v1 API:
9
+ #
10
+ # 1. **Admin tokens only.** Channel-scoped tokens are rejected outright;
11
+ # the controller looks up AdminApiToken and nothing else.
12
+ # 2. **broadcast_channel_id is required on every call.** Set it once via
13
+ # `Client.new(broadcast_channel_id:)` or `client.with_channel(id)` and
14
+ # the gem attaches it automatically; otherwise pass it per call.
15
+ #
16
+ # Every list endpoint pages with `limit:` (1..250, default 250) and
17
+ # `offset:`, and returns `{"data" => [...], "pagination" => {...}}`.
18
+ class Migration < Base
19
+ # Endpoints that are a plain paginated list of the channel's records.
20
+ COLLECTIONS = %i[
21
+ channels subscribers templates segments sequences email_servers
22
+ opt_in_forms broadcasts outbound_receipts webhook_endpoints tokens
23
+ suppressions tags users link_redirects link_clicks subscriber_histories
24
+ file_assets
25
+ ].freeze
26
+
27
+ COLLECTIONS.each do |collection|
28
+ define_method(collection) do |**params|
29
+ get("/api/migration/v1/#{collection}", params)
30
+ end
31
+ end
32
+
33
+ # Export summary: format version, channel identity, per-resource counts,
34
+ # and recent-history totals. Call this first to size an export.
35
+ #
36
+ # @param days_history [Integer] window for the time-bounded counts
37
+ # (broadcasts, receipts, histories). Server clamps to 1..365, default 90.
38
+ def manifest(**params)
39
+ get('/api/migration/v1/manifest', params)
40
+ end
41
+
42
+ # Binary contents of a stored file asset. Returns a String of bytes, not
43
+ # JSON — write it straight to disk.
44
+ def download_file_asset(id, **params)
45
+ @client.request(:get, "/api/migration/v1/file_assets/#{id}/download", params, raw: true)
46
+ end
47
+
48
+ # Pages through a collection, yielding each record.
49
+ #
50
+ # client.migration.each_record(:subscribers) { |sub| csv << sub }
51
+ #
52
+ # Stops when the server reports `has_more: false`, so it stays correct if
53
+ # the page size is clamped server-side.
54
+ def each_record(collection, limit: 250, **params, &block)
55
+ return enum_for(:each_record, collection, limit: limit, **params) unless block_given?
56
+
57
+ offset = 0
58
+ loop do
59
+ page = public_send(collection, limit: limit, offset: offset, **params)
60
+ records = Array(page['data'])
61
+ records.each(&block)
62
+
63
+ pagination = page['pagination'] || {}
64
+ break unless pagination['has_more']
65
+
66
+ # Advance by what the server actually returned — it clamps `limit`.
67
+ advanced = pagination['limit'] || records.size
68
+ break if advanced.to_i.zero?
69
+
70
+ offset += advanced.to_i
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end