kit-rb 0.0.0 → 0.1.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/CHANGELOG.md +28 -0
- data/README.md +56 -5
- data/Rakefile +17 -0
- data/lib/kit/client.rb +56 -0
- data/lib/kit/configuration.rb +7 -2
- data/lib/kit/connection.rb +37 -11
- data/lib/kit/errors.rb +15 -0
- data/lib/kit/oauth/client.rb +132 -0
- data/lib/kit/oauth/pkce.rb +42 -0
- data/lib/kit/oauth/token.rb +38 -0
- data/lib/kit/objects/broadcast.rb +26 -0
- data/lib/kit/objects/creator_profile.rb +13 -0
- data/lib/kit/objects/custom_field.rb +14 -0
- data/lib/kit/objects/email_template.rb +14 -0
- data/lib/kit/objects/form.rb +22 -0
- data/lib/kit/objects/post.rb +24 -0
- data/lib/kit/objects/purchase.rb +24 -0
- data/lib/kit/objects/raw.rb +12 -0
- data/lib/kit/objects/segment.rb +12 -0
- data/lib/kit/objects/sequence.rb +26 -0
- data/lib/kit/objects/sequence_email.rb +23 -0
- data/lib/kit/objects/snippet.rb +21 -0
- data/lib/kit/objects/subscriber.rb +25 -0
- data/lib/kit/objects/tag.rb +17 -0
- data/lib/kit/objects/webhook.rb +15 -0
- data/lib/kit/objects/webhook_endpoint.rb +22 -0
- data/lib/kit/pagination.rb +66 -0
- data/lib/kit/resources/account.rb +26 -1
- data/lib/kit/resources/base.rb +38 -6
- data/lib/kit/resources/broadcasts.rb +52 -0
- data/lib/kit/resources/bulk.rb +63 -0
- data/lib/kit/resources/custom_fields.rb +29 -0
- data/lib/kit/resources/email_templates.rb +13 -0
- data/lib/kit/resources/forms.rb +31 -0
- data/lib/kit/resources/posts.rb +18 -0
- data/lib/kit/resources/purchases.rb +26 -0
- data/lib/kit/resources/segments.rb +13 -0
- data/lib/kit/resources/sequences.rb +78 -0
- data/lib/kit/resources/snippets.rb +29 -0
- data/lib/kit/resources/subscribers.rb +68 -0
- data/lib/kit/resources/tags.rb +52 -0
- data/lib/kit/resources/webhook_endpoints.rb +46 -0
- data/lib/kit/resources/webhooks.rb +24 -0
- data/lib/kit/version.rb +1 -1
- data/lib/kit-rb.rb +34 -0
- data/sig/kit-rb.rbs +367 -1
- metadata +39 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fa9f06f61a89d938cb719fcbd0cad578e8dd6d6ffc35fcf9b17d356f6a9160b3
|
|
4
|
+
data.tar.gz: 6279b4801b2cd8738b11bcc43babad6b8654739683ace8ff86389e040b8a994b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3cc4008cae00b1fe78391e5816b3e19040103c86b5d4feedbc8a03fdce856325e969840f4d568c583f2f5a1bd579f69ffd403c7e515cfca611a3ea0eca24b636
|
|
7
|
+
data.tar.gz: 279c04700c82548a16c64637d72269d1ad36a06419228e5ba738e603f98f23983f60183078a79c5a166dee45bc40d5f36a50c4b841a969d020acdaa6bdebf8b7
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres
|
|
5
5
|
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.1.0] - 2026-09-04
|
|
8
|
+
|
|
9
|
+
First feature-complete release: the entire Kit API v4 surface — all 81 documented
|
|
10
|
+
operations across every resource — verified against the vendored OpenAPI document.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Full resource coverage via `client.`: `account` (colors, creator_profile,
|
|
14
|
+
email/growth stats), `subscribers` (list/get/create/update/unsubscribe, filter,
|
|
15
|
+
tags, stats, location), `tags`, `custom_fields`, `forms`, `sequences` (incl.
|
|
16
|
+
emails and subscribers), `broadcasts` (incl. stats and click reports),
|
|
17
|
+
`email_templates`, `segments`, `posts`, `snippets`, `purchases`, `webhooks`,
|
|
18
|
+
`webhook_endpoints`, and `bulk` (all eight batch operations).
|
|
19
|
+
- OAuth 2.0: `client_credentials` grant and RFC 7009 token `revoke`, alongside
|
|
20
|
+
the existing authorization-code / refresh / PKCE flows.
|
|
21
|
+
- `Base#one`/`#collection` helpers centralising single-object and cursor-list
|
|
22
|
+
plumbing (POST-based lists supported for the filter endpoints).
|
|
23
|
+
- Testing layers: WebMock unit specs, an OpenAPI list-envelope contract test,
|
|
24
|
+
VCR integration cassettes (secrets scrubbed) replayed in CI, `rake smoke`
|
|
25
|
+
(live read-only), and an opt-in e2e lifecycle. Full RBS + Steep.
|
|
26
|
+
- Trusted Publishing release workflow (GitHub Actions OIDC → RubyGems).
|
|
27
|
+
|
|
7
28
|
## [0.0.0] - 2026-09-04
|
|
8
29
|
|
|
9
30
|
Name-reservation release: the P0 foundation and a working `GET /v4/account`
|
|
@@ -16,4 +37,11 @@ vertical slice. Not yet feature-complete — resources land in 0.1.0 per docs/DE
|
|
|
16
37
|
- `http.rb`-backed `Kit::Connection` with JSON handling and a typed error
|
|
17
38
|
hierarchy (`Kit::AuthenticationError`, `NotFoundError`, `RateLimitError`, …).
|
|
18
39
|
- `GET /v4/account` vertical slice returning immutable `Data` value objects.
|
|
40
|
+
- Tags resource: list, create, update, tag/remove a subscriber, list a tag
|
|
41
|
+
subscribers.
|
|
42
|
+
- Subscribers resource: list (auto-paginating), get, create, update, unsubscribe.
|
|
43
|
+
- OAuth 2.0 authorization-code flow: `Kit::OAuth::Client` (authorize URL,
|
|
44
|
+
code exchange, single-use refresh), PKCE (S256) helper, and `Token` object.
|
|
45
|
+
- Cursor pagination engine (`Kit::Collection#auto_paging_each`) and automatic
|
|
46
|
+
429/5xx retry with backoff.
|
|
19
47
|
- RBS signatures for the public surface.
|
data/README.md
CHANGED
|
@@ -4,8 +4,9 @@ A modern, fully-typed Ruby client for the **Kit** (formerly ConvertKit) **API v4
|
|
|
4
4
|
|
|
5
5
|
The gem is named `kit-rb`; the public namespace is the clean `Kit`.
|
|
6
6
|
|
|
7
|
-
> Status:
|
|
8
|
-
>
|
|
7
|
+
> Status: the full v4 surface is implemented — every one of the 81 documented
|
|
8
|
+
> operations across all resources, verified against the vendored OpenAPI
|
|
9
|
+
> document. See [`docs/DESIGN.md`](docs/DESIGN.md).
|
|
9
10
|
|
|
10
11
|
## Install
|
|
11
12
|
|
|
@@ -28,7 +29,6 @@ client = Kit::Client.new(access_token: oauth_access_token)
|
|
|
28
29
|
|
|
29
30
|
info = client.account.get # => Kit::Objects::AccountInfo
|
|
30
31
|
info.account.plan_type # => "creator_pro"
|
|
31
|
-
info.user.email # => "you@example.com"
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Responses are immutable `Data` value objects. Errors are typed:
|
|
@@ -38,18 +38,69 @@ begin
|
|
|
38
38
|
client.account.get
|
|
39
39
|
rescue Kit::AuthenticationError => e # 401
|
|
40
40
|
warn e.status # => 401
|
|
41
|
-
rescue Kit::RateLimitError => e # 429
|
|
41
|
+
rescue Kit::RateLimitError => e # 429 — honours Retry-After
|
|
42
42
|
sleep e.retry_after
|
|
43
43
|
rescue Kit::APIError => e # any other non-2xx
|
|
44
44
|
warn e.errors # => ["..."] from Kit's body
|
|
45
45
|
end
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
Transient failures (429 and 5xx) are retried automatically with backoff.
|
|
49
|
+
|
|
50
|
+
## Resources
|
|
51
|
+
|
|
52
|
+
`client.` exposes: `account`, `subscribers`, `tags`, `custom_fields`, `forms`,
|
|
53
|
+
`sequences` (incl. its emails and subscribers), `broadcasts` (incl. stats and
|
|
54
|
+
click reports), `email_templates`, `segments`, `posts`, `snippets`, `purchases`,
|
|
55
|
+
`webhooks`, `webhook_endpoints`, and `bulk`.
|
|
56
|
+
|
|
57
|
+
### Pagination
|
|
58
|
+
|
|
59
|
+
List endpoints return a `Kit::Collection` — `Enumerable` over the current page,
|
|
60
|
+
with lazy cursor following:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
client.subscribers.list.each { |s| ... } # current page
|
|
64
|
+
client.subscribers.list.auto_paging_each { |s| ... } # every page, lazily
|
|
65
|
+
client.subscribers.list(status: "active", per_page: 100)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### OAuth 2.0
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
oauth = Kit::OAuth::Client.new(client_id: ID, client_secret: SECRET,
|
|
72
|
+
redirect_uri: "https://app.example/callback")
|
|
73
|
+
|
|
74
|
+
redirect_to oauth.authorization_url(state: session_token) # consent
|
|
75
|
+
token = oauth.exchange_code(params[:code]) # => Kit::OAuth::Token
|
|
76
|
+
token = oauth.refresh(token.refresh_token) # single-use refresh
|
|
77
|
+
oauth.revoke(token.access_token) # RFC 7009
|
|
78
|
+
|
|
79
|
+
client = Kit::Client.new(access_token: token.access_token)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Public clients (SPA/mobile/CLI) use PKCE via `Kit::OAuth::PKCE.generate` and omit
|
|
83
|
+
the client secret. `oauth.client_credentials` mints an app-only token (note: Kit
|
|
84
|
+
rejects it on the resource endpoints — account access needs the consent flow).
|
|
85
|
+
|
|
86
|
+
## Testing
|
|
87
|
+
|
|
88
|
+
The suite is layered:
|
|
89
|
+
|
|
90
|
+
- **Unit** — every method against WebMock stubs.
|
|
91
|
+
- **Contract** — each list resource is pinned to the vendored OpenAPI document,
|
|
92
|
+
so reading the wrong response envelope fails automatically.
|
|
93
|
+
- **Integration** — real recorded responses (VCR cassettes, secrets scrubbed)
|
|
94
|
+
replayed in CI, proving the live shapes still parse into our value objects.
|
|
95
|
+
- **Smoke** — `rake smoke` hits every read endpoint live (needs `KIT_API_KEY`).
|
|
96
|
+
- **E2E** — an opt-in (`KIT_E2E=1`) create→update→list→delete lifecycle that
|
|
97
|
+
cleans up after itself.
|
|
98
|
+
- **Types** — full RBS signatures, checked with Steep.
|
|
49
99
|
|
|
50
100
|
```sh
|
|
51
101
|
bin/setup
|
|
52
102
|
bundle exec rake # spec + rubocop + steep
|
|
103
|
+
bundle exec rake smoke # live read-only smoke (needs a key)
|
|
53
104
|
```
|
|
54
105
|
|
|
55
106
|
## License
|
data/Rakefile
CHANGED
|
@@ -14,4 +14,21 @@ task :steep do
|
|
|
14
14
|
sh "bundle exec steep check"
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
namespace :contract do
|
|
18
|
+
desc "Refresh the vendored Kit OpenAPI document the contract tests pin against"
|
|
19
|
+
task :fetch do
|
|
20
|
+
require "open-uri"
|
|
21
|
+
require_relative "spec/support/openapi_contract"
|
|
22
|
+
body = URI.parse(OpenAPIContract::SOURCE_URL).read
|
|
23
|
+
File.write(OpenAPIContract::PATH, body)
|
|
24
|
+
puts "Fetched #{OpenAPIContract::SOURCE_URL} -> #{OpenAPIContract::PATH} (#{body.bytesize} bytes)"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "Live read-only smoke test against the real API (needs KIT_API_KEY)"
|
|
29
|
+
task :smoke do
|
|
30
|
+
require_relative "spec/support/smoke"
|
|
31
|
+
Kit::Smoke.run
|
|
32
|
+
end
|
|
33
|
+
|
|
17
34
|
task default: %i[spec rubocop steep]
|
data/lib/kit/client.rb
CHANGED
|
@@ -21,5 +21,61 @@ module Kit
|
|
|
21
21
|
def account
|
|
22
22
|
@account ||= Resources::Account.new(@connection)
|
|
23
23
|
end
|
|
24
|
+
|
|
25
|
+
def subscribers
|
|
26
|
+
@subscribers ||= Resources::Subscribers.new(@connection)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def tags
|
|
30
|
+
@tags ||= Resources::Tags.new(@connection)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def custom_fields
|
|
34
|
+
@custom_fields ||= Resources::CustomFields.new(@connection)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def forms
|
|
38
|
+
@forms ||= Resources::Forms.new(@connection)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def sequences
|
|
42
|
+
@sequences ||= Resources::Sequences.new(@connection)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def broadcasts
|
|
46
|
+
@broadcasts ||= Resources::Broadcasts.new(@connection)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def webhooks
|
|
50
|
+
@webhooks ||= Resources::Webhooks.new(@connection)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def webhook_endpoints
|
|
54
|
+
@webhook_endpoints ||= Resources::WebhookEndpoints.new(@connection)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def email_templates
|
|
58
|
+
@email_templates ||= Resources::EmailTemplates.new(@connection)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def segments
|
|
62
|
+
@segments ||= Resources::Segments.new(@connection)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def posts
|
|
66
|
+
@posts ||= Resources::Posts.new(@connection)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def snippets
|
|
70
|
+
@snippets ||= Resources::Snippets.new(@connection)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def purchases
|
|
74
|
+
@purchases ||= Resources::Purchases.new(@connection)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def bulk
|
|
78
|
+
@bulk ||= Resources::Bulk.new(@connection)
|
|
79
|
+
end
|
|
24
80
|
end
|
|
25
81
|
end
|
data/lib/kit/configuration.rb
CHANGED
|
@@ -4,14 +4,19 @@ module Kit
|
|
|
4
4
|
# Immutable per-client configuration. Exactly one credential (api_key or
|
|
5
5
|
# access_token) must be supplied; the matching auth strategy is selected here.
|
|
6
6
|
class Configuration
|
|
7
|
-
attr_reader :auth, :base_url, :open_timeout, :read_timeout
|
|
7
|
+
attr_reader :auth, :base_url, :open_timeout, :read_timeout,
|
|
8
|
+
:max_retries, :retry_backoff, :max_backoff
|
|
8
9
|
|
|
9
10
|
def initialize(api_key: nil, access_token: nil, base_url: DEFAULT_BASE_URL,
|
|
10
|
-
open_timeout: 10, read_timeout: 30
|
|
11
|
+
open_timeout: 10, read_timeout: 30,
|
|
12
|
+
max_retries: 2, retry_backoff: 0.5, max_backoff: 30)
|
|
11
13
|
@auth = build_auth(api_key, access_token)
|
|
12
14
|
@base_url = base_url
|
|
13
15
|
@open_timeout = open_timeout
|
|
14
16
|
@read_timeout = read_timeout
|
|
17
|
+
@max_retries = max_retries # 0 disables automatic retries
|
|
18
|
+
@retry_backoff = retry_backoff # base seconds for exponential backoff
|
|
19
|
+
@max_backoff = max_backoff # cap on any single sleep
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
private
|
data/lib/kit/connection.rb
CHANGED
|
@@ -5,35 +5,46 @@ require "json"
|
|
|
5
5
|
|
|
6
6
|
module Kit
|
|
7
7
|
# The transport layer: builds requests with http.rb, injects auth and JSON
|
|
8
|
-
# headers, parses responses,
|
|
9
|
-
# hierarchy
|
|
8
|
+
# headers, parses responses, maps non-2xx statuses onto the typed error
|
|
9
|
+
# hierarchy, and retries transient failures (429 + 5xx) with backoff.
|
|
10
|
+
# Resources talk to the API only through this.
|
|
10
11
|
class Connection
|
|
11
12
|
JSON_TYPE = "application/json"
|
|
13
|
+
RETRYABLE = [RateLimitError, ServerError].freeze
|
|
12
14
|
|
|
13
15
|
def initialize(config)
|
|
14
16
|
@config = config
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
# Issues a request and returns the parsed JSON body (a Hash) on success.
|
|
20
|
+
# 429s honour `Retry-After`; 5xx use exponential backoff with jitter; both
|
|
21
|
+
# give up after `config.max_retries` and re-raise the typed error.
|
|
18
22
|
#
|
|
19
23
|
# @param method [Symbol] :get, :post, :put, :delete
|
|
20
24
|
# @param path [String] e.g. "/v4/account" (leading slash, no host)
|
|
21
25
|
# @param params [Hash] query string params
|
|
22
26
|
# @param body [Hash, nil] JSON request body
|
|
23
27
|
def request(method, path, params: {}, body: nil)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
attempt = 0
|
|
29
|
+
begin
|
|
30
|
+
handle(perform(method, path, params, body))
|
|
31
|
+
rescue *RETRYABLE => e
|
|
32
|
+
attempt += 1
|
|
33
|
+
raise if attempt > @config.max_retries
|
|
34
|
+
|
|
35
|
+
backoff_sleep(backoff_for(e, attempt))
|
|
36
|
+
retry
|
|
37
|
+
end
|
|
33
38
|
end
|
|
34
39
|
|
|
35
40
|
private
|
|
36
41
|
|
|
42
|
+
def perform(method, path, params, body)
|
|
43
|
+
client.request(method, "#{@config.base_url}#{path}", params: params, json: body)
|
|
44
|
+
rescue HTTP::Error => e
|
|
45
|
+
raise Error, "HTTP transport error: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
37
48
|
def client
|
|
38
49
|
HTTP
|
|
39
50
|
.headers(default_headers)
|
|
@@ -74,5 +85,20 @@ module Kit
|
|
|
74
85
|
klass.new(status: status, body: body, response: response)
|
|
75
86
|
end
|
|
76
87
|
end
|
|
88
|
+
|
|
89
|
+
# Seconds to wait before the next attempt: the server's Retry-After when it
|
|
90
|
+
# sent one (429), else exponential backoff (base * 2^(n-1)) with jitter,
|
|
91
|
+
# capped at config.max_backoff.
|
|
92
|
+
def backoff_for(error, attempt)
|
|
93
|
+
return error.retry_after if error.is_a?(RateLimitError) && error.retry_after
|
|
94
|
+
|
|
95
|
+
base = @config.retry_backoff * (2**(attempt - 1))
|
|
96
|
+
[base + (rand * @config.retry_backoff), @config.max_backoff].min
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Extracted so tests can stub the wait instead of really sleeping.
|
|
100
|
+
def backoff_sleep(seconds)
|
|
101
|
+
sleep(seconds)
|
|
102
|
+
end
|
|
77
103
|
end
|
|
78
104
|
end
|
data/lib/kit/errors.rb
CHANGED
|
@@ -52,6 +52,21 @@ module Kit
|
|
|
52
52
|
# 5xx — a Kit-side failure.
|
|
53
53
|
class ServerError < APIError; end
|
|
54
54
|
|
|
55
|
+
# An OAuth token-endpoint failure. `oauth_error` is the RFC 6749 `error` code
|
|
56
|
+
# (e.g. "invalid_grant") and `error_description` its human-readable detail.
|
|
57
|
+
class OAuthError < APIError
|
|
58
|
+
attr_reader :oauth_error, :error_description
|
|
59
|
+
|
|
60
|
+
def initialize(status:, body: nil, response: nil)
|
|
61
|
+
if body.is_a?(Hash)
|
|
62
|
+
@oauth_error = body["error"]
|
|
63
|
+
@error_description = body["error_description"]
|
|
64
|
+
end
|
|
65
|
+
super(@oauth_error && "OAuth error: #{@oauth_error} (#{@error_description})",
|
|
66
|
+
status: status, body: body, response: response)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
55
70
|
# Maps an HTTP status to the most specific error class above.
|
|
56
71
|
class Error
|
|
57
72
|
def self.class_for(status)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Kit
|
|
8
|
+
module OAuth
|
|
9
|
+
# Drives the OAuth 2.0 authorization-code flow for a Kit App Store app:
|
|
10
|
+
# build the authorize URL, exchange the returned code for a Token, and
|
|
11
|
+
# refresh it. Kit's OAuth grants a third-party app delegated access to a Kit
|
|
12
|
+
# account owner's data — it is not end-user login for a creator's own site.
|
|
13
|
+
#
|
|
14
|
+
# oauth = Kit::OAuth::Client.new(client_id: ID, client_secret: SECRET,
|
|
15
|
+
# redirect_uri: "https://app.example/callback")
|
|
16
|
+
# redirect_to oauth.authorization_url(state: session_token)
|
|
17
|
+
# token = oauth.exchange_code(params[:code]) # Kit::OAuth::Token
|
|
18
|
+
# token = oauth.refresh(token.refresh_token) # single-use refresh
|
|
19
|
+
# client = Kit::Client.new(access_token: token.access_token)
|
|
20
|
+
#
|
|
21
|
+
# Public clients (SPAs/mobile/CLIs) omit client_secret and use PKCE instead.
|
|
22
|
+
class Client
|
|
23
|
+
AUTHORIZE_PATH = "/v4/oauth/authorize"
|
|
24
|
+
TOKEN_PATH = "/v4/oauth/token"
|
|
25
|
+
REVOKE_PATH = "/v4/oauth/revoke"
|
|
26
|
+
|
|
27
|
+
def initialize(client_id:, client_secret: nil, redirect_uri: nil, base_url: DEFAULT_BASE_URL)
|
|
28
|
+
raise ConfigurationError, "client_id is required" if client_id.nil? || client_id.empty?
|
|
29
|
+
|
|
30
|
+
@client_id = client_id
|
|
31
|
+
@client_secret = client_secret
|
|
32
|
+
@redirect_uri = redirect_uri
|
|
33
|
+
@base_url = base_url
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The URL to redirect the Kit account owner to for consent.
|
|
37
|
+
def authorization_url(scope: "public", state: nil, code_challenge: nil,
|
|
38
|
+
code_challenge_method: nil, tenant_name: nil, redirect_uri: @redirect_uri)
|
|
39
|
+
query = {
|
|
40
|
+
client_id: @client_id,
|
|
41
|
+
response_type: "code",
|
|
42
|
+
redirect_uri: redirect_uri,
|
|
43
|
+
scope: scope,
|
|
44
|
+
state: state,
|
|
45
|
+
code_challenge: code_challenge,
|
|
46
|
+
code_challenge_method: code_challenge_method,
|
|
47
|
+
tenant_name: tenant_name
|
|
48
|
+
}.compact
|
|
49
|
+
"#{@base_url}#{AUTHORIZE_PATH}?#{URI.encode_www_form(query)}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Exchanges an authorization code for a Token. Pass code_verifier for PKCE.
|
|
53
|
+
def exchange_code(code, code_verifier: nil, redirect_uri: @redirect_uri)
|
|
54
|
+
token_request(
|
|
55
|
+
grant_type: "authorization_code",
|
|
56
|
+
code: code,
|
|
57
|
+
client_id: @client_id,
|
|
58
|
+
client_secret: @client_secret,
|
|
59
|
+
redirect_uri: redirect_uri,
|
|
60
|
+
code_verifier: code_verifier
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Client-credentials grant: exchanges the app's client_id/client_secret for
|
|
65
|
+
# a Bearer token with no user-consent step. Kit issues one (scope "public",
|
|
66
|
+
# ~48h, no refresh_token), but — verified 2026-09-04 — that token is
|
|
67
|
+
# rejected (401) by the v4 resource endpoints: reaching a creator's account
|
|
68
|
+
# data still requires the authorization-code flow (the app must first be
|
|
69
|
+
# authorized on that account). Use this only where a bare app token is
|
|
70
|
+
# expected; for account access, use #authorization_url + #exchange_code.
|
|
71
|
+
def client_credentials(scope: nil)
|
|
72
|
+
token_request(
|
|
73
|
+
grant_type: "client_credentials",
|
|
74
|
+
client_id: @client_id,
|
|
75
|
+
client_secret: @client_secret,
|
|
76
|
+
scope: scope
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Refreshes a token. Kit refresh tokens are single-use; the returned Token
|
|
81
|
+
# carries a new refresh_token to persist.
|
|
82
|
+
def refresh(refresh_token)
|
|
83
|
+
token_request(
|
|
84
|
+
grant_type: "refresh_token",
|
|
85
|
+
refresh_token: refresh_token,
|
|
86
|
+
client_id: @client_id,
|
|
87
|
+
client_secret: @client_secret
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Revokes an access or refresh token (RFC 7009). Returns true on success;
|
|
92
|
+
# per the RFC a 200 means the token is no longer valid regardless of its
|
|
93
|
+
# prior state, so an unknown/expired/already-revoked token also succeeds.
|
|
94
|
+
# token_type_hint is "access_token" or "refresh_token".
|
|
95
|
+
def revoke(token, token_type_hint: nil)
|
|
96
|
+
response = post_form(REVOKE_PATH, token: token, client_id: @client_id,
|
|
97
|
+
client_secret: @client_secret, token_type_hint: token_type_hint)
|
|
98
|
+
return true if (200..299).cover?(response.status.to_i)
|
|
99
|
+
|
|
100
|
+
raise OAuthError.new(status: response.status.to_i, body: parse_body(response), response: response)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def token_request(**form)
|
|
106
|
+
parse_token(post_form(TOKEN_PATH, form))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def post_form(path, form)
|
|
110
|
+
HTTP
|
|
111
|
+
.headers("Accept" => "application/json", "User-Agent" => "kit-rb/#{Kit::VERSION}")
|
|
112
|
+
.post("#{@base_url}#{path}", form: form.compact)
|
|
113
|
+
rescue HTTP::Error => e
|
|
114
|
+
raise Error, "HTTP transport error: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def parse_body(response)
|
|
118
|
+
JSON.parse(response.body.to_s)
|
|
119
|
+
rescue JSON::ParserError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_token(response)
|
|
124
|
+
status = response.status.to_i
|
|
125
|
+
body = parse_body(response)
|
|
126
|
+
return Token.from(body) if (200..299).cover?(status) && body
|
|
127
|
+
|
|
128
|
+
raise OAuthError.new(status: status, body: body, response: response)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "digest"
|
|
5
|
+
require "base64"
|
|
6
|
+
|
|
7
|
+
module Kit
|
|
8
|
+
module OAuth
|
|
9
|
+
# RFC 7636 Proof Key for Code Exchange (S256), for public clients (SPAs,
|
|
10
|
+
# mobile, CLIs) that cannot hold a client_secret. Generate a challenge before
|
|
11
|
+
# redirecting to the authorize URL, keep the verifier, and send the verifier
|
|
12
|
+
# on the token exchange:
|
|
13
|
+
#
|
|
14
|
+
# pkce = Kit::OAuth::PKCE.generate
|
|
15
|
+
# url = oauth.authorization_url(code_challenge: pkce.code_challenge,
|
|
16
|
+
# code_challenge_method: pkce.code_challenge_method)
|
|
17
|
+
# # ...redirect, get code back...
|
|
18
|
+
# token = oauth.exchange_code(code, code_verifier: pkce.code_verifier)
|
|
19
|
+
Challenge = Data.define(:code_verifier, :code_challenge, :code_challenge_method)
|
|
20
|
+
|
|
21
|
+
# S256 verifier/challenge generation.
|
|
22
|
+
module PKCE
|
|
23
|
+
METHOD = "S256"
|
|
24
|
+
|
|
25
|
+
# Builds a fresh verifier and its S256 challenge.
|
|
26
|
+
def self.generate
|
|
27
|
+
verifier = SecureRandom.urlsafe_base64(64).tr("=", "") # 43–128 unreserved chars
|
|
28
|
+
Challenge.new(
|
|
29
|
+
code_verifier: verifier,
|
|
30
|
+
code_challenge: challenge_for(verifier),
|
|
31
|
+
code_challenge_method: METHOD
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# base64url( SHA256( verifier ) ), unpadded.
|
|
36
|
+
def self.challenge_for(verifier)
|
|
37
|
+
digest = Digest::SHA256.digest(verifier)
|
|
38
|
+
Base64.urlsafe_encode64(digest, padding: false)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kit
|
|
4
|
+
module OAuth
|
|
5
|
+
# An OAuth token set from the token endpoint. Kit's refresh tokens are
|
|
6
|
+
# single-use: every refresh returns a *new* refresh_token, so persist the
|
|
7
|
+
# whole Token after each exchange/refresh.
|
|
8
|
+
Token = Data.define(
|
|
9
|
+
:access_token, :refresh_token, :token_type, :expires_in, :scope, :created_at
|
|
10
|
+
) do
|
|
11
|
+
def self.from(hash)
|
|
12
|
+
new(
|
|
13
|
+
access_token: hash["access_token"],
|
|
14
|
+
refresh_token: hash["refresh_token"],
|
|
15
|
+
token_type: hash["token_type"],
|
|
16
|
+
expires_in: hash["expires_in"],
|
|
17
|
+
scope: hash["scope"],
|
|
18
|
+
created_at: hash["created_at"]
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Unix time the access token expires, or nil when the fields are absent.
|
|
23
|
+
def expires_at
|
|
24
|
+
return nil unless created_at && expires_in
|
|
25
|
+
|
|
26
|
+
created_at + expires_in
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# True once past expiry (with an optional leeway in seconds).
|
|
30
|
+
def expired?(now: Time.now.to_i, leeway: 0)
|
|
31
|
+
exp = expires_at
|
|
32
|
+
return false unless exp
|
|
33
|
+
|
|
34
|
+
now >= (exp - leeway)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kit
|
|
4
|
+
module Objects
|
|
5
|
+
# A broadcast (a one-off email) as returned by /v4/broadcasts. `email_template`
|
|
6
|
+
# is a nested {id, name} hash; `subscriber_filter` is the raw filter array;
|
|
7
|
+
# `publication_id` ties a broadcast to its post when it was also published.
|
|
8
|
+
Broadcast = Data.define(
|
|
9
|
+
:id, :publication_id, :created_at, :subject, :preview_text, :description,
|
|
10
|
+
:content, :public, :published_at, :send_at, :thumbnail_alt, :thumbnail_url,
|
|
11
|
+
:public_url, :email_address, :email_template, :subscriber_filter, :status
|
|
12
|
+
) do
|
|
13
|
+
def self.from(hash)
|
|
14
|
+
new(
|
|
15
|
+
id: hash["id"], publication_id: hash["publication_id"], created_at: hash["created_at"],
|
|
16
|
+
subject: hash["subject"], preview_text: hash["preview_text"], description: hash["description"],
|
|
17
|
+
content: hash["content"], public: hash["public"], published_at: hash["published_at"],
|
|
18
|
+
send_at: hash["send_at"], thumbnail_alt: hash["thumbnail_alt"], thumbnail_url: hash["thumbnail_url"],
|
|
19
|
+
public_url: hash["public_url"], email_address: hash["email_address"],
|
|
20
|
+
email_template: hash["email_template"], subscriber_filter: hash["subscriber_filter"],
|
|
21
|
+
status: hash["status"]
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kit
|
|
4
|
+
module Objects
|
|
5
|
+
# A creator profile as returned by /v4/account/creator_profile.
|
|
6
|
+
CreatorProfile = Data.define(:name, :byline, :bio, :image_url, :profile_url) do
|
|
7
|
+
def self.from(hash)
|
|
8
|
+
new(name: hash["name"], byline: hash["byline"], bio: hash["bio"],
|
|
9
|
+
image_url: hash["image_url"], profile_url: hash["profile_url"])
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kit
|
|
4
|
+
module Objects
|
|
5
|
+
# A custom field as returned by /v4/custom_fields. `label` is what a creator
|
|
6
|
+
# sets; `key` is the derived attribute used on subscribers (e.g. a `label`
|
|
7
|
+
# of "Last name" yields a `key` of "last_name").
|
|
8
|
+
CustomField = Data.define(:id, :name, :key, :label) do
|
|
9
|
+
def self.from(hash)
|
|
10
|
+
new(id: hash["id"], name: hash["name"], key: hash["key"], label: hash["label"])
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kit
|
|
4
|
+
module Objects
|
|
5
|
+
# An email template as returned by /v4/email_templates. `is_default` marks the
|
|
6
|
+
# account's default; `category` groups templates in the UI.
|
|
7
|
+
EmailTemplate = Data.define(:id, :name, :is_default, :category) do
|
|
8
|
+
def self.from(hash)
|
|
9
|
+
new(id: hash["id"], name: hash["name"],
|
|
10
|
+
is_default: hash["is_default"], category: hash["category"])
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kit
|
|
4
|
+
module Objects
|
|
5
|
+
# A form as returned by /v4/forms. `type` is e.g. "embed"/"hosted"; `uid` is
|
|
6
|
+
# the public identifier used in embed URLs. `subscriber_count` is present
|
|
7
|
+
# only when the request asks to include it.
|
|
8
|
+
Form = Data.define(
|
|
9
|
+
:id, :name, :created_at, :type, :format,
|
|
10
|
+
:embed_js, :embed_url, :archived, :uid, :subscriber_count
|
|
11
|
+
) do
|
|
12
|
+
def self.from(hash)
|
|
13
|
+
new(
|
|
14
|
+
id: hash["id"], name: hash["name"], created_at: hash["created_at"],
|
|
15
|
+
type: hash["type"], format: hash["format"], embed_js: hash["embed_js"],
|
|
16
|
+
embed_url: hash["embed_url"], archived: hash["archived"], uid: hash["uid"],
|
|
17
|
+
subscriber_count: hash["subscriber_count"]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|