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.
- checksums.yaml +4 -4
- data/.githooks/pre-commit +21 -0
- data/.githooks/pre-push +6 -0
- data/CHANGELOG.md +83 -0
- data/README.md +58 -16
- data/docs/DESIGN.md +38 -14
- data/docs/TASKS.md +47 -0
- data/lib/kit/auth/api_key.rb +7 -0
- data/lib/kit/auth/credential.rb +22 -0
- data/lib/kit/auth/oauth.rb +7 -8
- data/lib/kit/connection.rb +53 -20
- data/lib/kit/errors.rb +52 -15
- data/lib/kit/oauth/token.rb +9 -0
- data/lib/kit/objects/account.rb +44 -3
- data/lib/kit/objects/broadcast_click.rb +17 -0
- data/lib/kit/objects/broadcast_stats.rb +30 -0
- data/lib/kit/objects/bulk_result.rb +39 -0
- data/lib/kit/objects/custom_field.rb +4 -2
- data/lib/kit/objects/email_stats.rb +25 -0
- data/lib/kit/objects/growth_stats.rb +20 -0
- data/lib/kit/objects/post.rb +4 -2
- data/lib/kit/objects/sequence_email.rb +5 -2
- data/lib/kit/objects/subscriber.rb +16 -9
- data/lib/kit/objects/subscriber_stats.rb +26 -0
- data/lib/kit/objects/tag.rb +5 -3
- data/lib/kit/objects/webhook_endpoint.rb +7 -2
- data/lib/kit/pagination.rb +47 -3
- data/lib/kit/resources/account.rb +9 -8
- data/lib/kit/resources/base.rb +46 -4
- data/lib/kit/resources/broadcasts.rb +39 -16
- data/lib/kit/resources/bulk.rb +18 -20
- data/lib/kit/resources/custom_fields.rb +2 -2
- data/lib/kit/resources/forms.rb +3 -3
- data/lib/kit/resources/posts.rb +1 -1
- data/lib/kit/resources/purchases.rb +10 -6
- data/lib/kit/resources/sequences.rb +49 -25
- data/lib/kit/resources/snippets.rb +13 -8
- data/lib/kit/resources/subscribers.rb +24 -11
- data/lib/kit/resources/tags.rb +6 -6
- data/lib/kit/resources/webhook_endpoints.rb +12 -4
- data/lib/kit/resources/webhooks.rb +1 -1
- data/lib/kit/version.rb +1 -1
- data/lib/kit/webhooks/delivery.rb +61 -0
- data/lib/kit/webhooks/events.rb +113 -0
- data/lib/kit/webhooks/signature.rb +100 -0
- data/lib/kit-rb.rb +10 -1
- data/sig/kit-rb.rbs +240 -37
- metadata +16 -2
- 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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
data/lib/kit/oauth/token.rb
CHANGED
|
@@ -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
|
data/lib/kit/objects/account.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/kit/objects/post.rb
CHANGED
|
@@ -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"],
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
data/lib/kit/objects/tag.rb
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module Kit
|
|
4
4
|
module Objects
|
|
5
|
-
# A tag as returned by /v4/tags.
|
|
6
|
-
|
|
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
|
data/lib/kit/pagination.rb
CHANGED
|
@@ -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
|
|
6
|
-
#
|
|
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")
|
|
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 })
|
|
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 —
|
|
30
|
+
# GET /v4/account/email_stats — account-wide email engagement stats.
|
|
31
31
|
def email_stats
|
|
32
|
-
|
|
32
|
+
one(:get, "/v4/account/email_stats", "stats", Objects::EmailStats)
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
# GET /v4/account/growth_stats —
|
|
35
|
+
# GET /v4/account/growth_stats — subscriber-growth stats; accepts
|
|
36
|
+
# starting/ending to bound the window.
|
|
36
37
|
def growth_stats(**params)
|
|
37
|
-
|
|
38
|
+
one(:get, "/v4/account/growth_stats", "stats", Objects::GrowthStats, params: params)
|
|
38
39
|
end
|
|
39
40
|
end
|
|
40
41
|
end
|