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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -0
  3. data/README.md +56 -5
  4. data/Rakefile +17 -0
  5. data/lib/kit/client.rb +56 -0
  6. data/lib/kit/configuration.rb +7 -2
  7. data/lib/kit/connection.rb +37 -11
  8. data/lib/kit/errors.rb +15 -0
  9. data/lib/kit/oauth/client.rb +132 -0
  10. data/lib/kit/oauth/pkce.rb +42 -0
  11. data/lib/kit/oauth/token.rb +38 -0
  12. data/lib/kit/objects/broadcast.rb +26 -0
  13. data/lib/kit/objects/creator_profile.rb +13 -0
  14. data/lib/kit/objects/custom_field.rb +14 -0
  15. data/lib/kit/objects/email_template.rb +14 -0
  16. data/lib/kit/objects/form.rb +22 -0
  17. data/lib/kit/objects/post.rb +24 -0
  18. data/lib/kit/objects/purchase.rb +24 -0
  19. data/lib/kit/objects/raw.rb +12 -0
  20. data/lib/kit/objects/segment.rb +12 -0
  21. data/lib/kit/objects/sequence.rb +26 -0
  22. data/lib/kit/objects/sequence_email.rb +23 -0
  23. data/lib/kit/objects/snippet.rb +21 -0
  24. data/lib/kit/objects/subscriber.rb +25 -0
  25. data/lib/kit/objects/tag.rb +17 -0
  26. data/lib/kit/objects/webhook.rb +15 -0
  27. data/lib/kit/objects/webhook_endpoint.rb +22 -0
  28. data/lib/kit/pagination.rb +66 -0
  29. data/lib/kit/resources/account.rb +26 -1
  30. data/lib/kit/resources/base.rb +38 -6
  31. data/lib/kit/resources/broadcasts.rb +52 -0
  32. data/lib/kit/resources/bulk.rb +63 -0
  33. data/lib/kit/resources/custom_fields.rb +29 -0
  34. data/lib/kit/resources/email_templates.rb +13 -0
  35. data/lib/kit/resources/forms.rb +31 -0
  36. data/lib/kit/resources/posts.rb +18 -0
  37. data/lib/kit/resources/purchases.rb +26 -0
  38. data/lib/kit/resources/segments.rb +13 -0
  39. data/lib/kit/resources/sequences.rb +78 -0
  40. data/lib/kit/resources/snippets.rb +29 -0
  41. data/lib/kit/resources/subscribers.rb +68 -0
  42. data/lib/kit/resources/tags.rb +52 -0
  43. data/lib/kit/resources/webhook_endpoints.rb +46 -0
  44. data/lib/kit/resources/webhooks.rb +24 -0
  45. data/lib/kit/version.rb +1 -1
  46. data/lib/kit-rb.rb +34 -0
  47. data/sig/kit-rb.rbs +367 -1
  48. metadata +39 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cd1696be6db17ef8f8d6a5c010d7e827bb914f7ea32e1b8e0e68a55f4a548cfc
4
- data.tar.gz: f68f531db1eaa19bd17f46f4a764f994419fbf01cf2349d141686f00c33ccc7f
3
+ metadata.gz: fa9f06f61a89d938cb719fcbd0cad578e8dd6d6ffc35fcf9b17d356f6a9160b3
4
+ data.tar.gz: 6279b4801b2cd8738b11bcc43babad6b8654739683ace8ff86389e040b8a994b
5
5
  SHA512:
6
- metadata.gz: fea1917e81a6eef748518e0609b9d0b4e6c5f347b5527d12ba5575dbf02a210ea3db32037163d98e30e83cb6d35a258a0a5a712a969306960ca9ffaedbafe612
7
- data.tar.gz: '058e607d279ad4c3f462a50495874c5d4ce8792dc73961ca193488c7e44e5442fc2cb057b3563fc295b382d2184e4c467f4742d1b4478ac4fb1ea7c4a3493d86'
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: early. P0 (foundations + the `GET /v4/account` vertical slice) is in
8
- > place; resources are landing per [`docs/DESIGN.md`](docs/DESIGN.md).
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
- ## Development
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
@@ -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
@@ -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, and maps non-2xx statuses onto the typed error
9
- # hierarchy. Resources talk to the API only through this.
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
- response = client.request(
25
- method,
26
- "#{@config.base_url}#{path}",
27
- params: params,
28
- json: body
29
- )
30
- handle(response)
31
- rescue HTTP::Error => e
32
- raise Error, "HTTP transport error: #{e.message}"
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