kit-rb 0.2.0 → 0.3.1

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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.githooks/pre-commit +21 -0
  3. data/.githooks/pre-push +6 -0
  4. data/CHANGELOG.md +84 -0
  5. data/README.md +66 -16
  6. data/docs/DESIGN.md +38 -14
  7. data/docs/TASKS.md +47 -0
  8. data/lib/kit/auth/api_key.rb +7 -0
  9. data/lib/kit/auth/credential.rb +22 -0
  10. data/lib/kit/auth/oauth.rb +7 -8
  11. data/lib/kit/configuration.rb +3 -2
  12. data/lib/kit/connection.rb +53 -20
  13. data/lib/kit/errors.rb +52 -15
  14. data/lib/kit/oauth/token.rb +9 -0
  15. data/lib/kit/objects/account.rb +44 -3
  16. data/lib/kit/objects/bulk_result.rb +39 -0
  17. data/lib/kit/objects/custom_field.rb +4 -2
  18. data/lib/kit/objects/post.rb +4 -2
  19. data/lib/kit/objects/sequence_email.rb +5 -2
  20. data/lib/kit/objects/subscriber.rb +16 -9
  21. data/lib/kit/objects/tag.rb +5 -3
  22. data/lib/kit/objects/webhook_endpoint.rb +7 -2
  23. data/lib/kit/pagination.rb +47 -3
  24. data/lib/kit/resources/account.rb +4 -4
  25. data/lib/kit/resources/base.rb +46 -4
  26. data/lib/kit/resources/broadcasts.rb +32 -13
  27. data/lib/kit/resources/bulk.rb +18 -20
  28. data/lib/kit/resources/custom_fields.rb +2 -2
  29. data/lib/kit/resources/forms.rb +3 -3
  30. data/lib/kit/resources/posts.rb +1 -1
  31. data/lib/kit/resources/purchases.rb +10 -6
  32. data/lib/kit/resources/sequences.rb +49 -25
  33. data/lib/kit/resources/snippets.rb +13 -8
  34. data/lib/kit/resources/subscribers.rb +23 -9
  35. data/lib/kit/resources/tags.rb +6 -6
  36. data/lib/kit/resources/webhook_endpoints.rb +12 -4
  37. data/lib/kit/resources/webhooks.rb +1 -1
  38. data/lib/kit/version.rb +1 -1
  39. data/lib/kit/webhooks/delivery.rb +61 -0
  40. data/lib/kit/webhooks/events.rb +113 -0
  41. data/lib/kit/webhooks/signature.rb +100 -0
  42. data/lib/kit-rb.rb +5 -0
  43. data/sig/kit-rb.rbs +172 -30
  44. metadata +19 -3
data/lib/kit/errors.rb CHANGED
@@ -8,24 +8,51 @@ module Kit
8
8
  # (e.g. no credentials supplied).
9
9
  class ConfigurationError < Error; end
10
10
 
11
+ # Base for failures below HTTP: the request never got a response. `cause`
12
+ # is the underlying http.rb exception.
13
+ class TransportError < Error; end
14
+ # The connection or read timed out (config.open_timeout / read_timeout).
15
+ class TimeoutError < TransportError; end
16
+ # The connection could not be established or was dropped (DNS, refused,
17
+ # reset, TLS).
18
+ class ConnectionError < TransportError; end
19
+
20
+ # Raised when a 2xx response does not have the shape the resource expects
21
+ # (a missing envelope key, a non-JSON body). Distinct from APIError because
22
+ # the request succeeded; the client and the API disagree about the payload.
23
+ # `body` is the parsed (or raw) response body for diagnosis.
24
+ class UnexpectedResponseError < Error
25
+ attr_reader :body
26
+
27
+ def initialize(message, body: nil)
28
+ @body = body
29
+ super(message)
30
+ end
31
+ end
32
+
11
33
  # Base for every error that carries an HTTP response. `status` is the code,
12
34
  # `body` the parsed JSON body (or the raw string when it wasn't JSON), and
13
- # `errors` the `errors` array Kit returns on validation failures.
35
+ # `errors` the `errors` array Kit returns on validation failures. `method`
36
+ # and `path` identify the request that failed (nil for OAuth token errors).
14
37
  class APIError < Error
15
- attr_reader :status, :body, :errors, :response
38
+ attr_reader :status, :body, :errors, :response, :method, :path
16
39
 
17
- def initialize(message = nil, status:, body: nil, response: nil)
40
+ def initialize(message = nil, status:, body: nil, response: nil, method: nil, path: nil)
18
41
  @status = status
19
42
  @body = body
20
43
  @response = response
44
+ @method = method
45
+ @path = path
21
46
  @errors = body.is_a?(Hash) ? Array(body["errors"]) : []
22
47
  super(message || default_message)
23
48
  end
24
49
 
25
50
  private
26
51
 
52
+ # e.g. "GET /v4/subscribers/1 failed with status 404: Not Found"
27
53
  def default_message
28
- base = "Kit API request failed with status #{status}"
54
+ request = method && path ? "#{method.to_s.upcase} #{path}" : "Kit API request"
55
+ base = "#{request} failed with status #{status}"
29
56
  @errors.empty? ? base : "#{base}: #{@errors.join(", ")}"
30
57
  end
31
58
  end
@@ -36,6 +63,12 @@ module Kit
36
63
  class AuthorizationError < APIError; end
37
64
  # 404 — no such resource.
38
65
  class NotFoundError < APIError; end
66
+ # 409 — the request conflicts with current state (e.g. rotating a webhook
67
+ # endpoint secret while a previous rotation is still in its grace period).
68
+ class ConflictError < APIError; end
69
+ # 413 — the request exceeds Kit's size quota (the bulk endpoints' enqueued
70
+ # data cap); split the batch.
71
+ class PayloadTooLargeError < APIError; end
39
72
  # 422 — the request was well-formed but semantically invalid.
40
73
  class UnprocessableEntityError < APIError; end
41
74
 
@@ -43,9 +76,9 @@ module Kit
43
76
  class RateLimitError < APIError
44
77
  attr_reader :retry_after
45
78
 
46
- def initialize(message = nil, status:, body: nil, response: nil, retry_after: nil)
79
+ def initialize(message = nil, status:, body: nil, response: nil, method: nil, path: nil, retry_after: nil)
47
80
  @retry_after = retry_after
48
- super(message, status: status, body: body, response: response)
81
+ super(message, status: status, body: body, response: response, method: method, path: path)
49
82
  end
50
83
  end
51
84
 
@@ -69,16 +102,20 @@ module Kit
69
102
 
70
103
  # Maps an HTTP status to the most specific error class above.
71
104
  class Error
105
+ STATUS_CLASSES = {
106
+ 401 => AuthenticationError,
107
+ 403 => AuthorizationError,
108
+ 404 => NotFoundError,
109
+ 409 => ConflictError,
110
+ 413 => PayloadTooLargeError,
111
+ 422 => UnprocessableEntityError,
112
+ 429 => RateLimitError
113
+ }.freeze
114
+
72
115
  def self.class_for(status)
73
- case status
74
- when 401 then AuthenticationError
75
- when 403 then AuthorizationError
76
- when 404 then NotFoundError
77
- when 422 then UnprocessableEntityError
78
- when 429 then RateLimitError
79
- when 500..599 then ServerError
80
- else APIError
81
- end
116
+ return ServerError if (500..599).cover?(status)
117
+
118
+ STATUS_CLASSES.fetch(status, APIError)
82
119
  end
83
120
  end
84
121
  end
@@ -19,6 +19,15 @@ module Kit
19
19
  )
20
20
  end
21
21
 
22
+ # Both tokens are masked in #inspect; #to_h still returns the real values
23
+ # for persistence.
24
+ def inspect
25
+ "#<data #{self.class.name} access_token=#{Auth::Credential.mask(access_token)}, " \
26
+ "refresh_token=#{Auth::Credential.mask(refresh_token)}, token_type=#{token_type.inspect}, " \
27
+ "expires_in=#{expires_in.inspect}, scope=#{scope.inspect}, created_at=#{created_at.inspect}>"
28
+ end
29
+ alias_method :to_s, :inspect
30
+
22
31
  # Unix time the access token expires, or nil when the fields are absent.
23
32
  def expires_at
24
33
  return nil unless created_at && expires_in
@@ -9,16 +9,57 @@ module Kit
9
9
  end
10
10
  end
11
11
 
12
+ # The account's timezone, nested under account.timezone.
13
+ Timezone = Data.define(:name, :friendly_name, :utc_offset) do
14
+ def self.from(hash)
15
+ new(name: hash["name"], friendly_name: hash["friendly_name"], utc_offset: hash["utc_offset"])
16
+ end
17
+ end
18
+
19
+ # Billing plan details, nested under account.plan.
20
+ Plan = Data.define(
21
+ :plan_type, :interval, :subscriber_limit, :on_trial, :trial_lapse_date, :renews_at, :cancels_at
22
+ ) do
23
+ def self.from(hash)
24
+ new(
25
+ plan_type: hash["plan_type"], interval: hash["interval"], subscriber_limit: hash["subscriber_limit"],
26
+ on_trial: hash["on_trial"], trial_lapse_date: hash["trial_lapse_date"],
27
+ renews_at: hash["renews_at"], cancels_at: hash["cancels_at"]
28
+ )
29
+ end
30
+ end
31
+
32
+ # One verified sender address, listed under account.sending_addresses.
33
+ SendingAddress = Data.define(
34
+ :email_address, :from_name, :status, :is_default, :is_verified, :is_dmarc_configured
35
+ ) do
36
+ def self.from(hash)
37
+ new(
38
+ email_address: hash["email_address"], from_name: hash["from_name"], status: hash["status"],
39
+ is_default: hash["is_default"], is_verified: hash["is_verified"],
40
+ is_dmarc_configured: hash["is_dmarc_configured"]
41
+ )
42
+ end
43
+ end
44
+
12
45
  # The account half of GET /v4/account. Extra fields Kit adds later are
13
- # ignored rather than crashing the client (forward-compatible).
14
- Account = Data.define(:id, :name, :plan_type, :primary_email_address, :created_at) do
46
+ # ignored rather than crashing the client (forward-compatible). `timezone`
47
+ # and `plan` are typed sub-objects (nil when absent); `sending_addresses` is
48
+ # an Array of SendingAddress.
49
+ Account = Data.define(
50
+ :id, :name, :plan_type, :primary_email_address, :created_at,
51
+ :timezone, :plan, :sending_addresses
52
+ ) do
15
53
  def self.from(hash)
16
54
  new(
17
55
  id: hash["id"],
18
56
  name: hash["name"],
19
57
  plan_type: hash["plan_type"],
20
58
  primary_email_address: hash["primary_email_address"],
21
- created_at: hash["created_at"]
59
+ created_at: hash["created_at"],
60
+ timezone: hash["timezone"] && Timezone.from(hash["timezone"]),
61
+ plan: hash["plan"] && Plan.from(hash["plan"]),
62
+ sending_addresses: Array(hash["sending_addresses"]).map { |address| SendingAddress.from(address) }
22
63
  )
23
64
  end
24
65
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ module Objects
5
+ # One rejected item from a bulk call: `item` is the input echoed back by
6
+ # Kit (the hash under "subscriber"/"tag"/... — the key varies per
7
+ # endpoint) and `errors` the validation messages for it.
8
+ BulkFailure = Data.define(:item, :errors) do
9
+ def self.from(hash)
10
+ new(item: hash.except("errors").values.first,
11
+ errors: Array(hash["errors"]))
12
+ end
13
+ end
14
+
15
+ # The outcome of a /v4/bulk call. Small batches are applied synchronously
16
+ # (200): `items` are the created/affected records as raw Hashes — their
17
+ # shape is endpoint-specific and partial — and `failures` the rejected
18
+ # inputs. Batches over Kit's inline threshold are queued (202, empty
19
+ # body): `async?` is true, `items`/`failures` are empty, and the full
20
+ # result is POSTed to the request's callback_url when processing ends.
21
+ BulkResult = Data.define(:items, :failures, :async) do
22
+ # `key` is the envelope key of the affected records ("subscribers",
23
+ # "tags", ...), or nil for the delete endpoints, which return failures only.
24
+ def self.from(status, body, key)
25
+ body = {} unless body.is_a?(Hash)
26
+ new(
27
+ items: key ? Array(body[key]) : [],
28
+ failures: Array(body["failures"]).map { |failure| BulkFailure.from(failure) },
29
+ async: status == 202
30
+ )
31
+ end
32
+
33
+ def async? = async
34
+
35
+ # True when Kit applied the batch now and rejected nothing.
36
+ def success? = !async && failures.empty?
37
+ end
38
+ end
39
+ end
@@ -5,9 +5,11 @@ module Kit
5
5
  # A custom field as returned by /v4/custom_fields. `label` is what a creator
6
6
  # sets; `key` is the derived attribute used on subscribers (e.g. a `label`
7
7
  # of "Last name" yields a `key` of "last_name").
8
- CustomField = Data.define(:id, :name, :key, :label) do
8
+ # `created_at` is returned by the bulk create endpoint only.
9
+ CustomField = Data.define(:id, :name, :key, :label, :created_at) do
9
10
  def self.from(hash)
10
- new(id: hash["id"], name: hash["name"], key: hash["key"], label: hash["label"])
11
+ new(id: hash["id"], name: hash["name"], key: hash["key"], label: hash["label"],
12
+ created_at: hash["created_at"])
11
13
  end
12
14
  end
13
15
  end
@@ -4,10 +4,12 @@ module Kit
4
4
  module Objects
5
5
  # A post (a broadcast published to the web) as returned by /v4/posts.
6
6
  # `publication_id` ties it back to the broadcast it was published from.
7
+ # `content` (the HTML body) is present on a single-post read and on list
8
+ # reads made with include_content: true; nil otherwise.
7
9
  Post = Data.define(
8
10
  :id, :publication_id, :created_at, :title, :slug, :description,
9
11
  :meta_description, :status, :published_at, :sent_at,
10
- :thumbnail_alt, :thumbnail_url, :is_paid, :public_url
12
+ :thumbnail_alt, :thumbnail_url, :is_paid, :public_url, :content
11
13
  ) do
12
14
  def self.from(hash)
13
15
  new(
@@ -16,7 +18,7 @@ module Kit
16
18
  meta_description: hash["meta_description"], status: hash["status"],
17
19
  published_at: hash["published_at"], sent_at: hash["sent_at"],
18
20
  thumbnail_alt: hash["thumbnail_alt"], thumbnail_url: hash["thumbnail_url"],
19
- is_paid: hash["is_paid"], public_url: hash["public_url"]
21
+ is_paid: hash["is_paid"], public_url: hash["public_url"], content: hash["content"]
20
22
  )
21
23
  end
22
24
  end
@@ -5,9 +5,11 @@ module Kit
5
5
  # An email within a sequence, as returned by
6
6
  # /v4/sequences/:sequence_id/emails. `position` is its order in the sequence;
7
7
  # `delay_value`/`delay_unit` set how long after the previous step it sends.
8
+ # `content` (the HTML body) is present on a single-email read, on
9
+ # create/update responses, and on list reads with include_content: true.
8
10
  SequenceEmail = Data.define(
9
11
  :id, :sequence_id, :subject, :preview_text, :email_address, :email_template_id,
10
- :published, :position, :delay_value, :delay_unit, :send_days, :stats
12
+ :published, :position, :delay_value, :delay_unit, :send_days, :content, :stats
11
13
  ) do
12
14
  def self.from(hash)
13
15
  new(
@@ -15,7 +17,8 @@ module Kit
15
17
  preview_text: hash["preview_text"], email_address: hash["email_address"],
16
18
  email_template_id: hash["email_template_id"], published: hash["published"],
17
19
  position: hash["position"], delay_value: hash["delay_value"],
18
- delay_unit: hash["delay_unit"], send_days: hash["send_days"], stats: hash["stats"]
20
+ delay_unit: hash["delay_unit"], send_days: hash["send_days"], content: hash["content"],
21
+ stats: hash["stats"]
19
22
  )
20
23
  end
21
24
  end
@@ -4,20 +4,27 @@ module Kit
4
4
  module Objects
5
5
  # A subscriber as returned by /v4/subscribers. `fields` holds custom-field
6
6
  # values; `location` is present on some responses; both are plain Hashes.
7
+ #
8
+ # Context fields, nil unless the endpoint supplies them:
9
+ # - `added_at`, `referrer`, `referrer_utm_parameters` — form/sequence
10
+ # subscriber lists (when this subscriber joined through that form/sequence)
11
+ # - `tagged_at` — a tag's subscriber list
12
+ # - `attribution`, `tags` — list reads made with include: "attribution,tags"
13
+ # - `tag_names`, `tag_ids`, `stats` — POST /v4/subscribers/filter
7
14
  Subscriber = Data.define(
8
15
  :id, :first_name, :email_address, :state,
9
- :created_at, :canceled_at, :location, :fields
16
+ :created_at, :canceled_at, :location, :fields,
17
+ :added_at, :tagged_at, :referrer, :referrer_utm_parameters,
18
+ :attribution, :tags, :tag_names, :tag_ids, :stats
10
19
  ) do
11
20
  def self.from(hash)
12
21
  new(
13
- id: hash["id"],
14
- first_name: hash["first_name"],
15
- email_address: hash["email_address"],
16
- state: hash["state"],
17
- created_at: hash["created_at"],
18
- canceled_at: hash["canceled_at"],
19
- location: hash["location"],
20
- fields: hash["fields"]
22
+ id: hash["id"], first_name: hash["first_name"], email_address: hash["email_address"],
23
+ state: hash["state"], created_at: hash["created_at"], canceled_at: hash["canceled_at"],
24
+ location: hash["location"], fields: hash["fields"],
25
+ added_at: hash["added_at"], tagged_at: hash["tagged_at"], referrer: hash["referrer"],
26
+ referrer_utm_parameters: hash["referrer_utm_parameters"], attribution: hash["attribution"],
27
+ tags: hash["tags"], tag_names: hash["tag_names"], tag_ids: hash["tag_ids"], stats: hash["stats"]
21
28
  )
22
29
  end
23
30
  end
@@ -2,14 +2,16 @@
2
2
 
3
3
  module Kit
4
4
  module Objects
5
- # A tag as returned by /v4/tags. Fields are all non-nil.
6
- Tag = Data.define(:id, :name, :created_at, :subscriber_count) do
5
+ # A tag as returned by /v4/tags. `tagged_at` is set only when the tag is
6
+ # read through a subscriber (/v4/subscribers/:id/tags): when it was applied.
7
+ Tag = Data.define(:id, :name, :created_at, :subscriber_count, :tagged_at) do
7
8
  def self.from(hash)
8
9
  new(
9
10
  id: hash["id"],
10
11
  name: hash["name"],
11
12
  created_at: hash["created_at"],
12
- subscriber_count: hash["subscriber_count"]
13
+ subscriber_count: hash["subscriber_count"],
14
+ tagged_at: hash["tagged_at"]
13
15
  )
14
16
  end
15
17
  end
@@ -5,16 +5,21 @@ module Kit
5
5
  # A webhook endpoint as returned by /v4/webhook_endpoints — the newer signed-
6
6
  # delivery model, with a secret that can be rotated. `events` is the list of
7
7
  # subscribed event names; `previous_secret_expires_at` is set after a rotate.
8
+ #
9
+ # `secret` (the `whsec_`-prefixed signing secret) is present only on the
10
+ # response to `create` and `rotate_secret` — Kit never returns it again, so
11
+ # persist it from that object. It is nil on every other read.
8
12
  WebhookEndpoint = Data.define(
9
13
  :id, :name, :url, :events, :status, :source, :description,
10
- :created_by_app, :created_at, :previous_secret_expires_at
14
+ :created_by_app, :created_at, :previous_secret_expires_at, :secret
11
15
  ) do
12
16
  def self.from(hash)
13
17
  new(
14
18
  id: hash["id"], name: hash["name"], url: hash["url"], events: hash["events"],
15
19
  status: hash["status"], source: hash["source"], description: hash["description"],
16
20
  created_by_app: hash["created_by_app"], created_at: hash["created_at"],
17
- previous_secret_expires_at: hash["previous_secret_expires_at"]
21
+ previous_secret_expires_at: hash["previous_secret_expires_at"],
22
+ secret: hash["secret"]
18
23
  )
19
24
  end
20
25
  end
@@ -3,17 +3,24 @@
3
3
  module Kit
4
4
  # The `pagination` object Kit returns alongside every list. Kit uses cursor
5
5
  # pagination: to walk forward, pass `after: end_cursor`; backward, `before:
6
- # start_cursor`.
6
+ # start_cursor`. `total_count` is present only when the request asked for it
7
+ # with `include_total_count: true` (nil otherwise).
7
8
  Pagination = Data.define(
8
- :has_previous_page, :has_next_page, :start_cursor, :end_cursor, :per_page
9
+ :has_previous_page, :has_next_page, :start_cursor, :end_cursor, :per_page, :total_count
9
10
  ) do
11
+ # total_count is optional so Pagination.new(...) without it keeps working.
12
+ def initialize(total_count: nil, **rest)
13
+ super
14
+ end
15
+
10
16
  def self.from(hash)
11
17
  new(
12
18
  has_previous_page: hash["has_previous_page"],
13
19
  has_next_page: hash["has_next_page"],
14
20
  start_cursor: hash["start_cursor"],
15
21
  end_cursor: hash["end_cursor"],
16
- per_page: hash["per_page"]
22
+ per_page: hash["per_page"],
23
+ total_count: hash["total_count"]
17
24
  )
18
25
  end
19
26
  end
@@ -29,8 +36,23 @@ module Kit
29
36
  class Collection
30
37
  include Enumerable
31
38
 
39
+ # Query params that must not be carried into a follow-up page request: a
40
+ # `before` cursor contradicts the `after` we add, and Kit asks that
41
+ # `include_total_count` be sent on the first page only.
42
+ FIRST_PAGE_ONLY = %i[before include_total_count].freeze
43
+
44
+ # The params for the page after `after`, derived from the original request.
45
+ def self.next_page_params(params, after)
46
+ params.reject { |key, _| FIRST_PAGE_ONLY.include?(key.to_sym) }.merge(after: after)
47
+ end
48
+
32
49
  attr_reader :data, :pagination
33
50
 
51
+ # Kit's total across all pages, when the first request asked for it.
52
+ def total_count
53
+ @pagination.total_count
54
+ end
55
+
34
56
  def initialize(data:, pagination:, &fetch_after)
35
57
  @data = data
36
58
  @pagination = pagination
@@ -42,6 +64,28 @@ module Kit
42
64
  @data.each(&)
43
65
  end
44
66
 
67
+ # Size of the current page (not the total across pages — see total_count).
68
+ def size
69
+ @data.size
70
+ end
71
+ alias length size
72
+
73
+ def empty?
74
+ @data.empty?
75
+ end
76
+
77
+ # Positional access into the current page.
78
+ def [](index)
79
+ @data[index]
80
+ end
81
+
82
+ # e.g. #<Kit::Collection[Kit::Objects::Tag] size=2 has_next_page=true total_count=57>
83
+ def inspect
84
+ element = @data.first&.class&.name
85
+ "#<#{self.class.name}#{"[#{element}]" if element} size=#{size} " \
86
+ "has_next_page=#{@pagination.has_next_page.inspect} total_count=#{total_count.inspect}>"
87
+ end
88
+
45
89
  # The next Collection, or nil when there is no next page.
46
90
  def next_page
47
91
  return nil unless @pagination.has_next_page
@@ -2,8 +2,8 @@
2
2
 
3
3
  module Kit
4
4
  module Resources
5
- # The /v4/account endpoints. P0 covers the current-account read; colors,
6
- # creator_profile, email_stats, and growth_stats follow in P1/P2.
5
+ # The /v4/account endpoints: the current account, its colour palette,
6
+ # creator profile, and account-wide email and growth stats.
7
7
  class Account < Base
8
8
  # GET /v4/account — current account and user info.
9
9
  #
@@ -14,12 +14,12 @@ module Kit
14
14
 
15
15
  # GET /v4/account/colors — the account's brand color palette (hex strings).
16
16
  def colors
17
- http_get("/v4/account/colors").fetch("colors")
17
+ extract(http_get("/v4/account/colors"), "colors")
18
18
  end
19
19
 
20
20
  # PUT /v4/account/colors — replace the palette; returns the saved colors.
21
21
  def update_colors(colors)
22
- http_put("/v4/account/colors", body: { colors: colors }).fetch("colors")
22
+ extract(http_put("/v4/account/colors", body: { colors: colors }), "colors")
23
23
  end
24
24
 
25
25
  # GET /v4/account/creator_profile
@@ -1,17 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "erb"
4
+
3
5
  module Kit
4
6
  module Resources
5
7
  # Shared base for every resource group. Holds the connection and exposes
6
8
  # verb helpers under `http_*` names so a resource can define public methods
7
9
  # like `get(id)` or `list` without colliding with the transport helpers.
8
10
  class Base
11
+ # Default for optional body keywords: "not given", as distinct from nil,
12
+ # which Kit accepts on some fields to clear them (e.g. send_days: nil).
13
+ OMIT = Object.new.freeze
14
+
9
15
  def initialize(connection)
10
16
  @connection = connection
11
17
  end
12
18
 
13
19
  private
14
20
 
21
+ # Drops the keywords the caller did not pass, keeping explicit nils.
22
+ def given(**attributes)
23
+ attributes.reject { |_, value| value.equal?(OMIT) }
24
+ end
25
+
15
26
  # Non-enveloped read (whole body) and list reads go through http_get;
16
27
  # deletes return no object and go through http_delete. Enveloped
17
28
  # single-object and list responses are built by `one` and `collection`.
@@ -35,13 +46,23 @@ module Kit
35
46
  @connection.request(:delete, path, params: params, body: body)
36
47
  end
37
48
 
49
+ # Renders an id into a path segment. Kit ids are integers; a String is
50
+ # accepted but percent-encoded so a value like "1/unsubscribe" cannot
51
+ # rewrite the route, and nil/blank raises before any request is made
52
+ # (a blank id would silently hit the parent list endpoint).
53
+ def path_id(value)
54
+ raise ArgumentError, "id must not be nil or blank" if value.nil? || value.to_s.strip.empty?
55
+
56
+ ERB::Util.url_encode(value.to_s)
57
+ end
58
+
38
59
  # Sends one request that returns a single wrapped object and builds it.
39
60
  # `key` is the envelope key (e.g. "subscriber"), `klass` the object built
40
61
  # via `klass.from`. Centralised so a resource never hand-writes the read/
41
62
  # build/return plumbing — it declares only verb, path, key, class, body.
42
63
  def one(verb, path, key, klass, body: nil, params: {})
43
64
  response = @connection.request(verb, path, params: params, body: body)
44
- klass.from(response.fetch(key))
65
+ klass.from(extract(response, key))
45
66
  end
46
67
 
47
68
  # Fetches a cursor-paginated list and wraps it in a Collection whose next
@@ -53,9 +74,30 @@ module Kit
53
74
  # endpoints pass verb: :post with a filter body, still paging by cursor.
54
75
  def collection(path, key, klass, params, verb: :get, body: nil)
55
76
  response = @connection.request(verb, path, params: params, body: body)
56
- data = response.fetch(key).map { |element| klass.from(element) }
57
- Collection.new(data: data, pagination: Pagination.from(response.fetch("pagination"))) do |after|
58
- collection(path, key, klass, params.merge(after: after), verb: verb, body: body)
77
+ data = extract(response, key).map { |element| klass.from(element) }
78
+ Collection.new(data: data, pagination: Pagination.from(extract(response, "pagination"))) do |after|
79
+ collection(path, key, klass, Collection.next_page_params(params, after), verb: verb, body: body)
80
+ end
81
+ end
82
+
83
+ # Reads `key` from a 2xx body, raising UnexpectedResponseError (a
84
+ # Kit::Error, so `rescue Kit::Error` still catches it) instead of the bare
85
+ # KeyError/NoMethodError a drifted or non-JSON response would otherwise
86
+ # produce deep inside the resource.
87
+ def extract(response, key)
88
+ return response.fetch(key) if response.is_a?(Hash) && response.key?(key)
89
+
90
+ raise UnexpectedResponseError.new(
91
+ "expected a JSON object with #{key.inspect} in the response, got #{describe(response)}",
92
+ body: response
93
+ )
94
+ end
95
+
96
+ def describe(response)
97
+ case response
98
+ when Hash then "keys #{response.keys.inspect}"
99
+ when nil then "an empty body"
100
+ else "a #{response.class} body"
59
101
  end
60
102
  end
61
103
  end
@@ -12,22 +12,41 @@ module Kit
12
12
 
13
13
  # GET /v4/broadcasts/:id
14
14
  def get(id)
15
- one(:get, "/v4/broadcasts/#{id}", "broadcast", Objects::Broadcast)
15
+ one(:get, "/v4/broadcasts/#{path_id(id)}", "broadcast", Objects::Broadcast)
16
16
  end
17
17
 
18
- # POST /v4/broadcasts
19
- def create(**attributes)
20
- one(:post, "/v4/broadcasts", "broadcast", Objects::Broadcast, body: attributes)
18
+ BROADCAST_FIELDS = %i[
19
+ subject preview_text content description public published_at send_at
20
+ email_address email_template_id thumbnail_alt thumbnail_url subscriber_filter
21
+ ].freeze
22
+
23
+ # POST /v4/broadcasts. `subscriber_filter` is an array of one group
24
+ # ({ all: | any: | none: [{ type: "segment"|"tag", ids: [...] }] }); omit
25
+ # it to send to every subscriber. Timestamps are ISO 8601 (UTC assumed).
26
+ def create(subject: OMIT, preview_text: OMIT, content: OMIT, description: OMIT, public: OMIT,
27
+ published_at: OMIT, send_at: OMIT, email_address: OMIT, email_template_id: OMIT,
28
+ thumbnail_alt: OMIT, thumbnail_url: OMIT, subscriber_filter: OMIT)
29
+ body = given(subject: subject, preview_text: preview_text, content: content, description: description,
30
+ public: public, published_at: published_at, send_at: send_at, email_address: email_address,
31
+ email_template_id: email_template_id, thumbnail_alt: thumbnail_alt,
32
+ thumbnail_url: thumbnail_url, subscriber_filter: subscriber_filter)
33
+ one(:post, "/v4/broadcasts", "broadcast", Objects::Broadcast, body: body)
21
34
  end
22
35
 
23
- # PUT /v4/broadcasts/:id
24
- def update(id, **attributes)
25
- one(:put, "/v4/broadcasts/#{id}", "broadcast", Objects::Broadcast, body: attributes)
36
+ # PUT /v4/broadcasts/:id — same fields as #create; only those passed change.
37
+ def update(id, subject: OMIT, preview_text: OMIT, content: OMIT, description: OMIT, public: OMIT,
38
+ published_at: OMIT, send_at: OMIT, email_address: OMIT, email_template_id: OMIT,
39
+ thumbnail_alt: OMIT, thumbnail_url: OMIT, subscriber_filter: OMIT)
40
+ body = given(subject: subject, preview_text: preview_text, content: content, description: description,
41
+ public: public, published_at: published_at, send_at: send_at, email_address: email_address,
42
+ email_template_id: email_template_id, thumbnail_alt: thumbnail_alt,
43
+ thumbnail_url: thumbnail_url, subscriber_filter: subscriber_filter)
44
+ one(:put, "/v4/broadcasts/#{path_id(id)}", "broadcast", Objects::Broadcast, body: body)
26
45
  end
27
46
 
28
47
  # DELETE /v4/broadcasts/:id
29
48
  def delete(id)
30
- http_delete("/v4/broadcasts/#{id}")
49
+ http_delete("/v4/broadcasts/#{path_id(id)}")
31
50
  nil
32
51
  end
33
52
 
@@ -38,17 +57,17 @@ module Kit
38
57
 
39
58
  # GET /v4/broadcasts/:id/stats — one broadcast's performance stats.
40
59
  def stats(id)
41
- one(:get, "/v4/broadcasts/#{id}/stats", "broadcast", Objects::BroadcastStats)
60
+ one(:get, "/v4/broadcasts/#{path_id(id)}/stats", "broadcast", Objects::BroadcastStats)
42
61
  end
43
62
 
44
63
  # GET /v4/broadcasts/:id/clicks — a cursor-paginated Collection of the
45
64
  # broadcast's clicked links. The API nests the array under "broadcast", so
46
65
  # this is built directly rather than through Base#collection.
47
66
  def clicks(id, **params)
48
- body = http_get("/v4/broadcasts/#{id}/clicks", params: params)
49
- rows = body.fetch("broadcast").fetch("clicks").map { |row| Objects::BroadcastClick.from(row) }
50
- Collection.new(data: rows, pagination: Pagination.from(body.fetch("pagination"))) do |after|
51
- clicks(id, **params, after: after)
67
+ body = http_get("/v4/broadcasts/#{path_id(id)}/clicks", params: params)
68
+ rows = extract(extract(body, "broadcast"), "clicks").map { |row| Objects::BroadcastClick.from(row) }
69
+ Collection.new(data: rows, pagination: Pagination.from(extract(body, "pagination"))) do |after|
70
+ clicks(id, **Collection.next_page_params(params, after))
52
71
  end
53
72
  end
54
73
  end