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.
@@ -2,8 +2,21 @@
2
2
 
3
3
  module Broadcast
4
4
  class DeliveryMethod
5
+ # ActionMailer delivers transactional mail, so the unsubscribe link is off
6
+ # unless the host app opts back in via broadcast_settings. The option is
7
+ # consumed here rather than forwarded: Configuration would reject it.
8
+ DEFAULT_INCLUDE_UNSUBSCRIBE_LINK = false
9
+
5
10
  def initialize(settings = {})
6
- @client = Client.new(**settings)
11
+ opts = settings.to_h.dup
12
+ @include_unsubscribe_link =
13
+ if opts.key?(:include_unsubscribe_link)
14
+ opts.delete(:include_unsubscribe_link)
15
+ else
16
+ DEFAULT_INCLUDE_UNSUBSCRIBE_LINK
17
+ end
18
+
19
+ @client = Client.new(**opts)
7
20
  end
8
21
 
9
22
  def deliver!(mail)
@@ -11,8 +24,15 @@ module Broadcast
11
24
  to: mail.to&.first,
12
25
  subject: mail.subject,
13
26
  body: extract_body(mail),
14
- reply_to: mail.reply_to&.first
27
+ reply_to: mail.reply_to&.first,
28
+ html_body: (true if mail.html_part),
29
+ include_unsubscribe_link: @include_unsubscribe_link
15
30
  )
31
+ rescue Broadcast::WarningError
32
+ # The send succeeded — warnings_mode: :raise is about surfacing ignored
33
+ # parameters, not reporting a delivery failure. Wrapping it in
34
+ # DeliveryError would tell ActionMailer the mail didn't go out.
35
+ raise
16
36
  rescue Broadcast::Error => e
17
37
  raise DeliveryError, "Failed to deliver email: #{e.message}"
18
38
  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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ # The installation-wide suppression list. Addresses on it never receive
6
+ # mail from any channel. All operations require an admin (system) API
7
+ # token — a channel token gets a 401.
8
+ #
9
+ # There is deliberately no `check` here: checking is a per-channel
10
+ # question (it reads the channel list too), so it lives on Suppressions.
11
+ class GlobalSuppressions < Base
12
+ # List global suppressions (250 per page, with `pagination` metadata;
13
+ # pass `page:`). Optional `email:` filters by partial match.
14
+ def list(**params)
15
+ get('/api/v1/global_suppressions.json', params)
16
+ end
17
+
18
+ # Add an address to the global list. Already-suppressed is a success
19
+ # (200 instead of 201).
20
+ def add(email)
21
+ post('/api/v1/global_suppressions.json', { email: email })
22
+ end
23
+
24
+ # Remove an address from the global list only. Channels that suppressed
25
+ # the same address on their own account keep their block.
26
+ def remove(email)
27
+ @client.request(:delete, '/api/v1/global_suppressions.json', { email: email })
28
+ end
29
+
30
+ # Add up to 10,000 addresses at once. Idempotent. Returns `added`,
31
+ # `already_suppressed`, and `invalid` counts.
32
+ def bulk_add(emails)
33
+ post('/api/v1/global_suppressions/bulk.json', { emails: emails })
34
+ end
35
+
36
+ # Remove up to 10,000 addresses at once. Returns `removed` and
37
+ # `not_found` counts.
38
+ def bulk_remove(emails)
39
+ @client.request(:delete, '/api/v1/global_suppressions/bulk.json', { emails: emails })
40
+ end
41
+ end
42
+ end
43
+ 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
@@ -24,6 +24,18 @@ module Broadcast
24
24
  # Nested settings hashes (theme_settings, automation_settings, security_settings,
25
25
  # trigger_settings, widget_settings) and arrays (opt_in_form_blocks_attributes,
26
26
  # opt_in_post_submission_blocks_attributes) are passed through verbatim.
27
+ #
28
+ # Core:
29
+ # label:, form_type:, widget_type:, enabled:, allowed_embedding_domains:
30
+ #
31
+ # Confirmation / welcome flow:
32
+ # confirmation_email_template_id: Template sent to confirm the address
33
+ # welcome_email_template_id: Template sent after confirmation
34
+ # confirmation_redirect_url: where to send the subscriber post-confirm
35
+ # include_unsubscribe_link_in_confirmation: boolean
36
+ #
37
+ # Custom fields declared in opt_in_form_blocks_attributes support a
38
+ # multi-line variant, which renders as a textarea on the hosted form.
27
39
  def create(**attrs)
28
40
  post('/api/v1/opt_in_forms', { opt_in_form: attrs })
29
41
  end
@@ -3,6 +3,22 @@
3
3
  module Broadcast
4
4
  module Resources
5
5
  class Subscribers < Base
6
+ # List subscribers (250 per page, with `pagination` metadata; pass `page:`).
7
+ #
8
+ # Filters, all optional and combinable:
9
+ # is_active: true | false
10
+ # source: exact match on the source string
11
+ # created_after: ISO-8601 timestamp
12
+ # created_before: ISO-8601 timestamp
13
+ # tags: array — AND logic, subscriber must have all of them
14
+ # email: partial (case-insensitive) match, not exact
15
+ # confirmation_status: 'confirmed' | 'unconfirmed'
16
+ # custom_data: hash — JSONB containment, e.g. { plan: 'pro' }
17
+ #
18
+ # An unparseable created_after/created_before is *ignored* by the server
19
+ # rather than rejected; it comes back as a `parameter_ignored` warning on
20
+ # the response, so a bad timestamp silently widens the result set unless
21
+ # you check `result.warnings`.
6
22
  def list(**params)
7
23
  get('/api/v1/subscribers.json', params)
8
24
  end
@@ -22,6 +38,14 @@ module Broadcast
22
38
  # When set, the subscriber is created in unconfirmed state
23
39
  # and a confirmation email is queued.
24
40
  # confirmation_template_id: custom confirmation template (used with double_opt_in: true)
41
+ #
42
+ # Admin tokens only:
43
+ # confirmed_at: backdate the confirmation timestamp on create.
44
+ # Intended for migrating an already-confirmed list off
45
+ # another provider. Ignored (with a warning) on update,
46
+ # and ignored entirely for channel-scoped tokens.
47
+ #
48
+ # Note `unsubscribed_at` is never settable here — use `unsubscribe(email)`.
25
49
  def create(**attrs)
26
50
  double_opt_in = attrs.delete(:double_opt_in)
27
51
  confirmation_template_id = attrs.delete(:confirmation_template_id)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Broadcast
4
+ module Resources
5
+ # The current channel's suppression list. Addresses on it never receive
6
+ # broadcasts, sequences, or transactionals from this channel.
7
+ #
8
+ # The installation-wide list is a separate resource — see
9
+ # GlobalSuppressions — but `check` reads across both on purpose: it
10
+ # answers the question an integration actually asks, "will this address
11
+ # receive mail?".
12
+ class Suppressions < Base
13
+ # List the channel's suppressions (250 per page, with `pagination`
14
+ # metadata; pass `page:`). Optional `email:` filters by partial,
15
+ # case-insensitive match.
16
+ def list(**params)
17
+ get('/api/v1/suppressions.json', params)
18
+ end
19
+
20
+ # Add an address to the channel's suppression list.
21
+ #
22
+ # Adding an address that is already suppressed is a success (the server
23
+ # answers 200 instead of 201), so callers do not have to check first.
24
+ def add(email)
25
+ post('/api/v1/suppressions.json', { email: email })
26
+ end
27
+
28
+ # Remove an address from the channel's suppression list. Returns
29
+ # `removed: false` (not an error) when the address was not on it.
30
+ # Does not touch the global list.
31
+ def remove(email)
32
+ @client.request(:delete, '/api/v1/suppressions.json', { email: email })
33
+ end
34
+
35
+ # Add up to 10,000 addresses at once. Idempotent: a retried batch cannot
36
+ # duplicate. Returns `added`, `already_suppressed`, and `invalid` counts.
37
+ def bulk_add(emails)
38
+ post('/api/v1/suppressions/bulk.json', { emails: emails })
39
+ end
40
+
41
+ # Remove up to 10,000 addresses at once. Returns `removed` and
42
+ # `not_found` counts.
43
+ def bulk_remove(emails)
44
+ @client.request(:delete, '/api/v1/suppressions/bulk.json', { emails: emails })
45
+ end
46
+
47
+ # Will this address receive mail? Reads across both the global and the
48
+ # channel list — a globally blocked address reports `suppressed: true`
49
+ # here even though it is absent from the channel's own list. The
50
+ # response's `scope` says which list matched.
51
+ def check(email)
52
+ get('/api/v1/suppressions/check.json', { email: email })
53
+ end
54
+ end
55
+ end
56
+ end
@@ -11,6 +11,23 @@ module Broadcast
11
11
  get("/api/v1/templates/#{id}")
12
12
  end
13
13
 
14
+ # Create a template. Attrs are wrapped under `template:` on the wire.
15
+ #
16
+ # Content:
17
+ # label:, subject:, preheader:, body:, html_body:
18
+ #
19
+ # Confirmation templates (double opt-in):
20
+ # template_purpose: marks the template's role, e.g. 'confirmation'
21
+ # confirmation_text: copy shown in the confirmation email
22
+ # default_confirmation: make this the channel's default confirmation template
23
+ # confirmation_page_settings: per-state page copy, keyed by state, each
24
+ # taking { heading:, body: } — e.g.
25
+ # { 'confirmed' => { heading: 'You're in',
26
+ # body: 'Thanks for confirming.' },
27
+ # 'expired' => { heading: 'Link expired', body: '...' } }
28
+ #
29
+ # Anything the server doesn't recognize comes back as an
30
+ # `unrecognized_parameter` warning on the response rather than an error.
14
31
  def create(**attrs)
15
32
  post('/api/v1/templates', { template: attrs })
16
33
  end
@@ -3,6 +3,8 @@
3
3
  module Broadcast
4
4
  module Resources
5
5
  class Transactionals < Base
6
+ MAX_IDEMPOTENCY_KEY_LENGTH = 255
7
+
6
8
  # Send a transactional email.
7
9
  #
8
10
  # Required:
@@ -20,11 +22,29 @@ module Broadcast
20
22
  # Holds the email until the recipient confirms.
21
23
  # confirmation_template_id: custom confirmation template (used with double_opt_in: true)
22
24
  # subscriber: { first_name:, last_name: } — populates Subscriber on first send
25
+ # idempotency_key: see below
26
+ #
27
+ # Idempotency
28
+ # -----------
29
+ # Pass `idempotency_key:` to make a retry safe. The server stores the
30
+ # response for 24 hours keyed on (token, key) and replays it rather than
31
+ # sending a second email. Check `result.idempotent_replay?` to tell a
32
+ # replay from a fresh send.
33
+ #
34
+ # client.transactionals.create(to: 'a@b.com', subject: 'Receipt',
35
+ # body: '<p>Thanks</p>',
36
+ # idempotency_key: "receipt-#{order.id}")
37
+ #
38
+ # The key is part of a fingerprint over method + full path + body:
39
+ # - same key, same payload, still running -> Broadcast::ConflictError (409)
40
+ # - same key, *different* payload -> Broadcast::ValidationError (422)
41
+ # That 422 means "this key was already used for something else", not that
42
+ # the email was invalid — don't retry it with the same key.
23
43
  # rubocop:disable Metrics/ParameterLists -- mirrors the API's flat param surface
24
44
  def create(to:, subject: nil, body: nil, reply_to: nil, preheader: nil,
25
45
  template_id: nil, include_unsubscribe_link: nil,
26
46
  double_opt_in: nil, confirmation_template_id: nil,
27
- subscriber: nil, **extra)
47
+ subscriber: nil, idempotency_key: nil, **extra)
28
48
  # rubocop:enable Metrics/ParameterLists
29
49
  payload = { to: to }
30
50
  payload[:subject] = subject unless subject.nil?
@@ -38,12 +58,29 @@ module Broadcast
38
58
  payload[:subscriber] = subscriber unless subscriber.nil?
39
59
  payload.merge!(extra) unless extra.empty?
40
60
 
41
- post('/api/v1/transactionals.json', payload)
61
+ @client.request(:post, '/api/v1/transactionals.json', payload,
62
+ headers: idempotency_headers(idempotency_key))
42
63
  end
43
64
 
44
65
  def get_transactional(id)
45
66
  get("/api/v1/transactionals/#{id}.json")
46
67
  end
68
+
69
+ private
70
+
71
+ def idempotency_headers(key)
72
+ return {} if key.nil?
73
+
74
+ key = key.to_s.strip
75
+ return {} if key.empty?
76
+
77
+ if key.length > MAX_IDEMPOTENCY_KEY_LENGTH
78
+ raise ArgumentError,
79
+ "idempotency_key must be #{MAX_IDEMPOTENCY_KEY_LENGTH} characters or fewer (got #{key.length})"
80
+ end
81
+
82
+ { 'Idempotency-Key' => key }
83
+ end
47
84
  end
48
85
  end
49
86
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module Broadcast
6
+ # A single entry from the API's `warnings` array (docs: api-response-warnings).
7
+ # The API raises these on successful 2xx responses when it accepted the request
8
+ # but ignored part of it — an unrecognized parameter, a parameter that only
9
+ # applies in another mode, a value the server overrode.
10
+ #
11
+ # `param` is a dot-path to the offending parameter (e.g. "subscriber.foo").
12
+ # The API never includes submitted values, so a warning is safe to log.
13
+ Warning = Struct.new(:code, :param, :message) do
14
+ def to_s
15
+ param ? "[#{code}] #{param}: #{message}" : "[#{code}] #{message}"
16
+ end
17
+ end
18
+
19
+ # Parsed X-RateLimit-* response headers. `reset` is the time the current
20
+ # window rolls over, not a duration.
21
+ RateLimit = Struct.new(:limit, :remaining, :reset)
22
+
23
+ # The value returned by every JSON API call.
24
+ #
25
+ # Response subclasses Hash rather than wrapping it, so everything that worked
26
+ # against the raw parsed body still works — `result['id']`, `result.is_a?(Hash)`,
27
+ # `result.dig(...)`, equality against a plain Hash. The response metadata the
28
+ # API sends alongside the body is exposed as extra readers.
29
+ #
30
+ # result = client.subscribers.create(email: 'user@example.com', foo: 'bar')
31
+ # result['id'] # => 42 (unchanged from v0.2)
32
+ # result.warnings # => [#<Warning code="unrecognized_parameter" ...>]
33
+ # result.rate_limit.remaining # => 118
34
+ # result.status # => 201
35
+ #
36
+ # Non-Hash JSON bodies (a bare array) are returned as-is and carry no metadata.
37
+ class Response < Hash
38
+ attr_reader :status, :headers
39
+
40
+ class << self
41
+ # Wraps a parsed JSON body when it is a Hash; passes anything else through.
42
+ def build(parsed, status:, headers: {})
43
+ return parsed unless parsed.is_a?(::Hash)
44
+
45
+ response = new
46
+ response.replace(parsed)
47
+ response.attach_metadata(status: status, headers: headers)
48
+ response
49
+ end
50
+ end
51
+
52
+ # @api private — set once by .build immediately after construction
53
+ def attach_metadata(status:, headers:)
54
+ @status = status
55
+ @headers = headers
56
+ self
57
+ end
58
+
59
+ def warnings
60
+ @warnings ||= Array(self['warnings']).filter_map do |entry|
61
+ next unless entry.is_a?(::Hash)
62
+
63
+ Warning.new(code: entry['code'], param: entry['param'], message: entry['message'])
64
+ end
65
+ end
66
+
67
+ def warnings?
68
+ !warnings.empty?
69
+ end
70
+
71
+ def rate_limit
72
+ return @rate_limit if defined?(@rate_limit)
73
+
74
+ limit = header('x-ratelimit-limit')
75
+ @rate_limit = if limit.nil?
76
+ nil
77
+ else
78
+ RateLimit.new(
79
+ limit: limit.to_i,
80
+ remaining: header('x-ratelimit-remaining')&.to_i,
81
+ reset: parse_time(header('x-ratelimit-reset'))
82
+ )
83
+ end
84
+ end
85
+
86
+ # True when the API replayed a stored response for a repeated
87
+ # Idempotency-Key rather than performing the write again.
88
+ def idempotent_replay?
89
+ header('idempotency-replayed') == 'true'
90
+ end
91
+
92
+ private
93
+
94
+ def header(name)
95
+ (@headers || {})[name]
96
+ end
97
+
98
+ def parse_time(value)
99
+ value && Time.iso8601(value)
100
+ rescue ArgumentError
101
+ nil
102
+ end
103
+ end
104
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Broadcast
4
- VERSION = '0.2.0'
4
+ VERSION = '0.4.0'
5
5
  end