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.
@@ -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
- def send_email(to:, subject: nil, body: nil, reply_to: nil)
43
- transactionals.create(to: to, subject: subject, body: body, reply_to: reply_to)
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
- uri = build_uri(path, method, payload)
92
-
93
- retry_with_backoff { execute(method, uri, payload) }
94
- rescue Net::OpenTimeout, Net::ReadTimeout => e
95
- raise Broadcast::TimeoutError, "Request timeout: #{e.message}"
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
- @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