broadcast-ruby 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +87 -0
- data/Gemfile.lock +3 -2
- data/README.md +272 -17
- data/SDK-COVERAGE.md +422 -0
- data/lib/broadcast/client.rb +36 -136
- data/lib/broadcast/configuration.rb +53 -4
- data/lib/broadcast/connection.rb +278 -0
- data/lib/broadcast/debug_logger.rb +64 -0
- data/lib/broadcast/delivery_method.rb +5 -0
- data/lib/broadcast/errors.rb +27 -1
- data/lib/broadcast/resources/autopilots.rb +100 -0
- data/lib/broadcast/resources/discovery.rb +36 -0
- data/lib/broadcast/resources/migration.rb +75 -0
- data/lib/broadcast/resources/opt_in_forms.rb +12 -0
- data/lib/broadcast/resources/subscribers.rb +24 -0
- data/lib/broadcast/resources/templates.rb +17 -0
- data/lib/broadcast/resources/transactionals.rb +39 -2
- data/lib/broadcast/response.rb +104 -0
- data/lib/broadcast/version.rb +1 -1
- data/lib/broadcast/webhook.rb +34 -0
- data/lib/broadcast.rb +6 -0
- metadata +9 -3
- data/.rubocop.yml +0 -50
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/broadcast/errors.rb
CHANGED
|
@@ -13,11 +13,37 @@ module Broadcast
|
|
|
13
13
|
|
|
14
14
|
class NotFoundError < APIError; end
|
|
15
15
|
|
|
16
|
-
|
|
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
|