kit-rb 0.2.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 +70 -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/bulk_result.rb +39 -0
- data/lib/kit/objects/custom_field.rb +4 -2
- 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/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 +4 -4
- data/lib/kit/resources/base.rb +46 -4
- data/lib/kit/resources/broadcasts.rb +32 -13
- 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 +23 -9
- 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 +5 -0
- data/sig/kit-rb.rbs +170 -29
- metadata +11 -1
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,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
|
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
|
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
|
data/lib/kit/resources/base.rb
CHANGED
|
@@ -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
|
|
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
|
|
57
|
-
Collection.new(data: data, pagination: Pagination.from(response
|
|
58
|
-
collection(path, key, klass,
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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,
|
|
25
|
-
|
|
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
|
|
50
|
-
Collection.new(data: rows, pagination: Pagination.from(body
|
|
51
|
-
clicks(id, **params, 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
|