broadcast-ruby 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +146 -0
- data/Gemfile.lock +3 -2
- data/README.md +434 -22
- data/SDK-COVERAGE.md +469 -0
- data/lib/broadcast/client.rb +65 -138
- data/lib/broadcast/configuration.rb +53 -4
- data/lib/broadcast/connection.rb +278 -0
- data/lib/broadcast/debug_logger.rb +64 -0
- data/lib/broadcast/delivery_method.rb +22 -2
- data/lib/broadcast/errors.rb +27 -1
- data/lib/broadcast/resources/autopilots.rb +100 -0
- data/lib/broadcast/resources/discovery.rb +36 -0
- data/lib/broadcast/resources/global_suppressions.rb +43 -0
- data/lib/broadcast/resources/migration.rb +75 -0
- data/lib/broadcast/resources/opt_in_forms.rb +12 -0
- data/lib/broadcast/resources/subscribers.rb +24 -0
- data/lib/broadcast/resources/suppressions.rb +56 -0
- data/lib/broadcast/resources/templates.rb +17 -0
- data/lib/broadcast/resources/transactionals.rb +39 -2
- data/lib/broadcast/response.rb +104 -0
- data/lib/broadcast/version.rb +1 -1
- data/lib/broadcast/webhook.rb +34 -0
- data/lib/broadcast.rb +8 -0
- metadata +11 -3
- data/.rubocop.yml +0 -50
data/lib/broadcast/client.rb
CHANGED
|
@@ -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,15 +35,48 @@ 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.
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
# preheader, idempotency_key, and other advanced options.
|
|
39
|
+
# `html_body` tells Broadcast the body is already HTML. Without it the send
|
|
40
|
+
# is recorded as plain text and the payload is wrapped in Broadcast's own
|
|
41
|
+
# <html><body> shell, so an HTML mail arrives as two nested documents.
|
|
42
|
+
#
|
|
43
|
+
# `include_unsubscribe_link` lets the caller suppress the unsubscribe
|
|
44
|
+
# footer and List-Unsubscribe header. Transactional mail wants that off: a
|
|
45
|
+
# one-click unsubscribe on a password reset marks the person unsubscribed
|
|
46
|
+
# and silently drops them from every sequence and broadcast.
|
|
47
|
+
# rubocop:disable Metrics/ParameterLists -- mirrors the API's flat param surface
|
|
48
|
+
def send_email(to:, subject: nil, body: nil, reply_to: nil,
|
|
49
|
+
html_body: nil, include_unsubscribe_link: nil)
|
|
50
|
+
# rubocop:enable Metrics/ParameterLists
|
|
51
|
+
opts = { to: to, subject: subject, body: body, reply_to: reply_to }
|
|
52
|
+
opts[:html_body] = html_body unless html_body.nil?
|
|
53
|
+
opts[:include_unsubscribe_link] = include_unsubscribe_link unless include_unsubscribe_link.nil?
|
|
54
|
+
|
|
55
|
+
transactionals.create(**opts)
|
|
44
56
|
end
|
|
45
57
|
|
|
46
58
|
def get_email(id)
|
|
47
59
|
transactionals.get_transactional(id)
|
|
48
60
|
end
|
|
49
61
|
|
|
62
|
+
# --- Discovery (convenience shims) ---
|
|
63
|
+
|
|
64
|
+
def whoami
|
|
65
|
+
discovery.whoami
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def status
|
|
69
|
+
discovery.status
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def prime
|
|
73
|
+
discovery.prime
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def skill
|
|
77
|
+
discovery.skill
|
|
78
|
+
end
|
|
79
|
+
|
|
50
80
|
# --- Resource sub-clients ---
|
|
51
81
|
|
|
52
82
|
def subscribers
|
|
@@ -85,14 +115,36 @@ module Broadcast
|
|
|
85
115
|
@email_servers ||= Resources::EmailServers.new(self)
|
|
86
116
|
end
|
|
87
117
|
|
|
118
|
+
def autopilots
|
|
119
|
+
@autopilots ||= Resources::Autopilots.new(self)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def discovery
|
|
123
|
+
@discovery ||= Resources::Discovery.new(self)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The current channel's suppression list (plus `check`, which reads the
|
|
127
|
+
# global list too).
|
|
128
|
+
def suppressions
|
|
129
|
+
@suppressions ||= Resources::Suppressions.new(self)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The installation-wide suppression list. Requires an admin (system) API
|
|
133
|
+
# token.
|
|
134
|
+
def global_suppressions
|
|
135
|
+
@global_suppressions ||= Resources::GlobalSuppressions.new(self)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Read-only export endpoints under /api/migration/v1. Requires an admin
|
|
139
|
+
# (system) API token.
|
|
140
|
+
def migration
|
|
141
|
+
@migration ||= Resources::Migration.new(self)
|
|
142
|
+
end
|
|
143
|
+
|
|
88
144
|
# @api private
|
|
89
|
-
def request(method, path, body_or_params = nil)
|
|
145
|
+
def request(method, path, body_or_params = nil, headers: {}, raw: false)
|
|
90
146
|
payload = inject_channel_scope(body_or_params)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
retry_with_backoff { execute(method, uri, payload) }
|
|
94
|
-
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
95
|
-
raise Broadcast::TimeoutError, "Request timeout: #{e.message}"
|
|
147
|
+
@connection.request(method, path, payload, headers: headers, raw: raw)
|
|
96
148
|
end
|
|
97
149
|
|
|
98
150
|
private
|
|
@@ -117,130 +169,5 @@ module Broadcast
|
|
|
117
169
|
payload[:broadcast_channel_id] = channel_id
|
|
118
170
|
payload
|
|
119
171
|
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
172
|
end
|
|
246
173
|
end
|
|
@@ -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
|