kit-rb 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) 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 +83 -0
  5. data/README.md +58 -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/connection.rb +53 -20
  12. data/lib/kit/errors.rb +52 -15
  13. data/lib/kit/oauth/token.rb +9 -0
  14. data/lib/kit/objects/account.rb +44 -3
  15. data/lib/kit/objects/broadcast_click.rb +17 -0
  16. data/lib/kit/objects/broadcast_stats.rb +30 -0
  17. data/lib/kit/objects/bulk_result.rb +39 -0
  18. data/lib/kit/objects/custom_field.rb +4 -2
  19. data/lib/kit/objects/email_stats.rb +25 -0
  20. data/lib/kit/objects/growth_stats.rb +20 -0
  21. data/lib/kit/objects/post.rb +4 -2
  22. data/lib/kit/objects/sequence_email.rb +5 -2
  23. data/lib/kit/objects/subscriber.rb +16 -9
  24. data/lib/kit/objects/subscriber_stats.rb +26 -0
  25. data/lib/kit/objects/tag.rb +5 -3
  26. data/lib/kit/objects/webhook_endpoint.rb +7 -2
  27. data/lib/kit/pagination.rb +47 -3
  28. data/lib/kit/resources/account.rb +9 -8
  29. data/lib/kit/resources/base.rb +46 -4
  30. data/lib/kit/resources/broadcasts.rb +39 -16
  31. data/lib/kit/resources/bulk.rb +18 -20
  32. data/lib/kit/resources/custom_fields.rb +2 -2
  33. data/lib/kit/resources/forms.rb +3 -3
  34. data/lib/kit/resources/posts.rb +1 -1
  35. data/lib/kit/resources/purchases.rb +10 -6
  36. data/lib/kit/resources/sequences.rb +49 -25
  37. data/lib/kit/resources/snippets.rb +13 -8
  38. data/lib/kit/resources/subscribers.rb +24 -11
  39. data/lib/kit/resources/tags.rb +6 -6
  40. data/lib/kit/resources/webhook_endpoints.rb +12 -4
  41. data/lib/kit/resources/webhooks.rb +1 -1
  42. data/lib/kit/version.rb +1 -1
  43. data/lib/kit/webhooks/delivery.rb +61 -0
  44. data/lib/kit/webhooks/events.rb +113 -0
  45. data/lib/kit/webhooks/signature.rb +100 -0
  46. data/lib/kit-rb.rb +10 -1
  47. data/sig/kit-rb.rbs +240 -37
  48. metadata +16 -2
  49. data/lib/kit/objects/raw.rb +0 -12
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,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ module Objects
5
+ # One clicked link in a broadcast's click report
6
+ # (/v4/broadcasts/:id/clicks), with its click totals and rates.
7
+ BroadcastClick = Data.define(
8
+ :id, :url, :unique_clicks, :click_to_delivery_rate, :click_to_open_rate
9
+ ) do
10
+ def self.from(hash)
11
+ new(id: hash["id"], url: hash["url"], unique_clicks: hash["unique_clicks"],
12
+ click_to_delivery_rate: hash["click_to_delivery_rate"],
13
+ click_to_open_rate: hash["click_to_open_rate"])
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ module Objects
5
+ # Performance stats for a broadcast, as returned by /v4/broadcasts/:id/stats
6
+ # and each row of /v4/broadcasts/stats. The API nests the metrics under a
7
+ # "stats" key alongside id/subject/send_at; this flattens them into one
8
+ # object (subject/send_at are absent on the single-broadcast endpoint).
9
+ BroadcastStats = Data.define(
10
+ :id, :subject, :send_at, :recipients, :open_rate, :emails_opened,
11
+ :click_rate, :unsubscribe_rate, :unsubscribes, :total_clicks,
12
+ :show_total_clicks, :status, :progress, :open_tracking_disabled,
13
+ :click_tracking_disabled
14
+ ) do
15
+ def self.from(hash)
16
+ stats = hash["stats"] || {}
17
+ new(
18
+ id: hash["id"], subject: hash["subject"], send_at: hash["send_at"],
19
+ recipients: stats["recipients"], open_rate: stats["open_rate"],
20
+ emails_opened: stats["emails_opened"], click_rate: stats["click_rate"],
21
+ unsubscribe_rate: stats["unsubscribe_rate"], unsubscribes: stats["unsubscribes"],
22
+ total_clicks: stats["total_clicks"], show_total_clicks: stats["show_total_clicks"],
23
+ status: stats["status"], progress: stats["progress"],
24
+ open_tracking_disabled: stats["open_tracking_disabled"],
25
+ click_tracking_disabled: stats["click_tracking_disabled"]
26
+ )
27
+ end
28
+ end
29
+ end
30
+ 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
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ module Objects
5
+ # Account-wide email engagement stats (/v4/account/email_stats), covering the
6
+ # window [starting, ending].
7
+ EmailStats = Data.define(
8
+ :sent, :clicked, :opened, :email_stats_mode,
9
+ :open_tracking_enabled, :click_tracking_enabled,
10
+ :starting, :ending, :open_rate, :click_rate, :unsubscribe_rate, :bounce_rate
11
+ ) do
12
+ def self.from(hash)
13
+ new(
14
+ sent: hash["sent"], clicked: hash["clicked"], opened: hash["opened"],
15
+ email_stats_mode: hash["email_stats_mode"],
16
+ open_tracking_enabled: hash["open_tracking_enabled"],
17
+ click_tracking_enabled: hash["click_tracking_enabled"],
18
+ starting: hash["starting"], ending: hash["ending"],
19
+ open_rate: hash["open_rate"], click_rate: hash["click_rate"],
20
+ unsubscribe_rate: hash["unsubscribe_rate"], bounce_rate: hash["bounce_rate"]
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ module Objects
5
+ # Account subscriber-growth stats (/v4/account/growth_stats) over the window
6
+ # [starting, ending].
7
+ GrowthStats = Data.define(
8
+ :cancellations, :net_new_subscribers, :new_subscribers, :subscribers,
9
+ :starting, :ending
10
+ ) do
11
+ def self.from(hash)
12
+ new(
13
+ cancellations: hash["cancellations"], net_new_subscribers: hash["net_new_subscribers"],
14
+ new_subscribers: hash["new_subscribers"], subscribers: hash["subscribers"],
15
+ starting: hash["starting"], ending: hash["ending"]
16
+ )
17
+ end
18
+ end
19
+ end
20
+ 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
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ module Objects
5
+ # Engagement stats for a subscriber (/v4/subscribers/:id/stats). The API
6
+ # nests the metrics under "stats" next to the id; this flattens them.
7
+ SubscriberStats = Data.define(
8
+ :id, :sent, :opened, :clicked, :bounced, :open_rate, :click_rate,
9
+ :last_sent, :last_opened, :last_clicked,
10
+ :sends_since_last_open, :sends_since_last_click
11
+ ) do
12
+ def self.from(hash)
13
+ stats = hash["stats"] || {}
14
+ new(
15
+ id: hash["id"], sent: stats["sent"], opened: stats["opened"],
16
+ clicked: stats["clicked"], bounced: stats["bounced"],
17
+ open_rate: stats["open_rate"], click_rate: stats["click_rate"],
18
+ last_sent: stats["last_sent"], last_opened: stats["last_opened"],
19
+ last_clicked: stats["last_clicked"],
20
+ sends_since_last_open: stats["sends_since_last_open"],
21
+ sends_since_last_click: stats["sends_since_last_click"]
22
+ )
23
+ end
24
+ end
25
+ end
26
+ 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
@@ -27,14 +27,15 @@ module Kit
27
27
  one(:get, "/v4/account/creator_profile", "profile", Objects::CreatorProfile)
28
28
  end
29
29
 
30
- # GET /v4/account/email_stats — returns the raw stats hash.
30
+ # GET /v4/account/email_stats — account-wide email engagement stats.
31
31
  def email_stats
32
- http_get("/v4/account/email_stats").fetch("stats")
32
+ one(:get, "/v4/account/email_stats", "stats", Objects::EmailStats)
33
33
  end
34
34
 
35
- # GET /v4/account/growth_stats — raw stats hash; accepts starting/ending.
35
+ # GET /v4/account/growth_stats — subscriber-growth stats; accepts
36
+ # starting/ending to bound the window.
36
37
  def growth_stats(**params)
37
- http_get("/v4/account/growth_stats", params: params).fetch("stats")
38
+ one(:get, "/v4/account/growth_stats", "stats", Objects::GrowthStats, params: params)
38
39
  end
39
40
  end
40
41
  end