kit-rb 0.2.0 → 0.3.1
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 +84 -0
- data/README.md +66 -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/configuration.rb +3 -2
- 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 +172 -30
- metadata +19 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 42b307eda9b14ab7bdabb1d966e5aba3dadd6bcd8633bfba23ea7dc98e5666fa
|
|
4
|
+
data.tar.gz: 0e537baf7538b197928df6cb66acaad1e02fca60f8b67cec80408a0f6f1f2c3d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 25ab9b37e0df8ccbf9e14ad35f016e74a0cce0532c7d6bccac1ff89f209da6e21400d5b8b23395dc9999daa9f93b39c1fa0f69c0ab47b3d0b61d30f7ec48769b
|
|
7
|
+
data.tar.gz: e2ca310dcd9e907b8b834460c4f01e792df15a4d6ba715b1888cd0e4f23b3c784a0c967b1944641821aaa2ca8f86af2a8195c43ba7cc4c96487f0a0c38a11432
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# Lint the Ruby files that are actually staged so the commit gate matches the
|
|
5
|
+
# commit. RuboCop exits non-zero on any offense, which set -e turns into an
|
|
6
|
+
# aborted commit — no output pattern is parsed, so a singular "1 offense
|
|
7
|
+
# detected" can no longer slip through.
|
|
8
|
+
# (A while-read loop rather than mapfile: macOS ships bash 3.2, which lacks it.)
|
|
9
|
+
staged=()
|
|
10
|
+
while IFS= read -r file; do
|
|
11
|
+
staged+=("$file")
|
|
12
|
+
done < <(
|
|
13
|
+
git diff --cached --name-only --diff-filter=ACM |
|
|
14
|
+
grep -E '(\.(rb|rake|gemspec)|Rakefile|Gemfile)$' || true
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if [ "${#staged[@]}" -eq 0 ]; then
|
|
18
|
+
exit 0
|
|
19
|
+
fi
|
|
20
|
+
|
|
21
|
+
exec bundle exec rubocop --force-exclusion "${staged[@]}"
|
data/.githooks/pre-push
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,83 @@ 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
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.3.1] - 2026-09-06
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `write_timeout` (default 30 s) alongside `open_timeout`/`read_timeout`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- The `http` dependency is `>= 5.2, < 7.0`: http 6.x is supported.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- The write timeout was never set, so on http 5 it fell back to http.rb's
|
|
19
|
+
0.25 s default and a large request body (a bulk batch) could time out
|
|
20
|
+
while being sent.
|
|
21
|
+
|
|
22
|
+
## [0.3.0] - 2026-09-06
|
|
23
|
+
|
|
24
|
+
The hardening pass (`docs/TASKS.md`): two real-API bugs fixed, the surface
|
|
25
|
+
completed to all 83 documented operations, receiver-side webhook support, and
|
|
26
|
+
contract tests over every envelope, request body and 204.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
- The two operations 0.2.0 lacked: `subscribers.update_location` (PATCH) and
|
|
30
|
+
`webhook_endpoints.update` (PATCH: rename, change URL, pause/resume with
|
|
31
|
+
`status`, replace `events`). All 83 documented operations now have a method.
|
|
32
|
+
- Incoming webhooks: `Kit::Webhooks::Signature.verify!`/`verify?` (HMAC-SHA256
|
|
33
|
+
`X-Kit-Signature`, replay window, rotation-aware, multiple secrets),
|
|
34
|
+
`Kit::Webhooks::Delivery.from_request`/`parse` with typed `Event`s, and the
|
|
35
|
+
event-name constants `Kit::Webhooks::Events` (28) / `LegacyEvents` (15, with
|
|
36
|
+
`REQUIRED_PARAM`).
|
|
37
|
+
- `Kit::Objects::WebhookEndpoint#secret` — the signing secret Kit returns only on
|
|
38
|
+
`create` and `rotate_secret`.
|
|
39
|
+
- `Pagination#total_count` / `Collection#total_count` (send
|
|
40
|
+
`include_total_count: true`); `Collection#size`, `#length`, `#empty?`, `#[]`
|
|
41
|
+
and a compact `#inspect`.
|
|
42
|
+
- Response fields the spec declares: `Account#timezone`/`#plan` (typed) and
|
|
43
|
+
`#sending_addresses`; `Post#content`; `SequenceEmail#content`;
|
|
44
|
+
`Subscriber#added_at`/`#tagged_at`/`#referrer`/`#referrer_utm_parameters`/
|
|
45
|
+
`#attribution`/`#tags`/`#tag_names`/`#tag_ids`/`#stats`; `Tag#tagged_at`;
|
|
46
|
+
`CustomField#created_at`.
|
|
47
|
+
- `sequences.get`/`sequences.email` accept `include: "stats"`;
|
|
48
|
+
`subscribers.stats` accepts `email_sent_after`/`email_sent_before`.
|
|
49
|
+
- Error classes: `ConflictError` (409), `PayloadTooLargeError` (413),
|
|
50
|
+
`UnexpectedResponseError` (a 2xx whose body is not the documented shape),
|
|
51
|
+
`TransportError` with `TimeoutError` and `ConnectionError` (the http.rb
|
|
52
|
+
exception is kept as `cause`). `APIError#method`/`#path`; messages now read
|
|
53
|
+
`"GET /v4/subscribers/1 failed with status 404: ..."`.
|
|
54
|
+
- `Connection#request_with_status`.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
- **Breaking:** the `bulk` methods return `Kit::Objects::BulkResult`
|
|
58
|
+
(`items`, typed `failures`, `async?` for a 202) instead of the raw Hash.
|
|
59
|
+
- **Breaking:** `broadcasts.create/update`, `sequences.create/update`,
|
|
60
|
+
`sequences.create_email/update_email`, `snippets.create/update` and
|
|
61
|
+
`purchases.create` take explicit keyword arguments (the fields the spec
|
|
62
|
+
documents) instead of `**attributes`; an unknown field raises
|
|
63
|
+
`ArgumentError`. Fields not passed are omitted; an explicit `nil` is sent,
|
|
64
|
+
which Kit uses to clear `send_days`/`email_template_id`/`send_at`.
|
|
65
|
+
- **Breaking:** `subscribers.unsubscribe` returns `nil` (the API answers 204).
|
|
66
|
+
- Retries: `Retry-After` is capped at `max_backoff`; a 5xx or transport
|
|
67
|
+
failure is retried only for idempotent verbs (never a POST); a 429 is still
|
|
68
|
+
retried for every verb.
|
|
69
|
+
- Follow-up page requests drop `include_total_count` and any `before` cursor.
|
|
70
|
+
- `Client`, `Configuration`, `Connection`, `Auth::*` and `OAuth::Token`
|
|
71
|
+
mask credentials in `#inspect`.
|
|
72
|
+
- Path ids are percent-encoded; a nil/blank id raises `ArgumentError`.
|
|
73
|
+
- One http.rb client is built per `Connection` instead of per request.
|
|
74
|
+
|
|
75
|
+
### Fixed
|
|
76
|
+
- `subscribers.unsubscribe` raised `NoMethodError` against the real API (204,
|
|
77
|
+
no body).
|
|
78
|
+
- `webhook_endpoints.create`/`rotate_secret` silently discarded the one-time
|
|
79
|
+
signing secret.
|
|
80
|
+
- A response with an unexpected shape raised `KeyError`/`NoMethodError`
|
|
81
|
+
instead of a `Kit::Error`.
|
|
82
|
+
- The error-mapping specs really slept through retries (60 s per CI run).
|
|
83
|
+
|
|
7
84
|
## [0.2.0] - 2026-09-04
|
|
8
85
|
|
|
9
86
|
### Changed
|
|
@@ -58,3 +135,10 @@ vertical slice. Not yet feature-complete — resources land in 0.1.0 per docs/DE
|
|
|
58
135
|
- Cursor pagination engine (`Kit::Collection#auto_paging_each`) and automatic
|
|
59
136
|
429/5xx retry with backoff.
|
|
60
137
|
- RBS signatures for the public surface.
|
|
138
|
+
|
|
139
|
+
[Unreleased]: https://github.com/linyiru/kit-rb/compare/v0.3.1...HEAD
|
|
140
|
+
[0.3.1]: https://github.com/linyiru/kit-rb/compare/v0.3.0...v0.3.1
|
|
141
|
+
[0.3.0]: https://github.com/linyiru/kit-rb/compare/v0.2.0...v0.3.0
|
|
142
|
+
[0.2.0]: https://github.com/linyiru/kit-rb/compare/v0.1.0...v0.2.0
|
|
143
|
+
[0.1.0]: https://github.com/linyiru/kit-rb/compare/7ce3329...v0.1.0
|
|
144
|
+
[0.0.0]: https://rubygems.org/gems/kit-rb/versions/0.0.0
|
data/README.md
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
# kit-rb
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/kit-rb)
|
|
4
|
+
[](https://rubygems.org/gems/kit-rb)
|
|
5
|
+
[](https://github.com/linyiru/kit-rb/actions/workflows/main.yml)
|
|
6
|
+
[](https://github.com/linyiru/kit-rb/actions/workflows/contract-drift.yml)
|
|
7
|
+
[](https://www.ruby-lang.org/)
|
|
8
|
+
[](sig/kit-rb.rbs)
|
|
9
|
+
[](LICENSE.txt)
|
|
10
|
+
|
|
3
11
|
A modern, fully-typed Ruby client for the **Kit** (formerly ConvertKit) **API v4**.
|
|
4
12
|
|
|
5
13
|
The gem is named `kit-rb`; the public namespace is the clean `Kit`.
|
|
6
14
|
|
|
7
|
-
> Status: the full v4 surface is implemented — every one of the
|
|
8
|
-
> operations across all resources,
|
|
9
|
-
>
|
|
15
|
+
> Status: the full v4 surface is implemented — every one of the 83 documented
|
|
16
|
+
> operations across all resources, pinned to the vendored OpenAPI document by
|
|
17
|
+
> contract tests. See [`docs/DESIGN.md`](docs/DESIGN.md).
|
|
10
18
|
|
|
11
19
|
## Install
|
|
12
20
|
|
|
@@ -31,21 +39,25 @@ info = client.account.get # => Kit::Objects::AccountInfo
|
|
|
31
39
|
info.account.plan_type # => "creator_pro"
|
|
32
40
|
```
|
|
33
41
|
|
|
34
|
-
Responses are immutable `Data` value objects. Errors are typed
|
|
42
|
+
Responses are immutable `Data` value objects. Errors are typed, and every one
|
|
43
|
+
is a `Kit::Error`:
|
|
35
44
|
|
|
36
45
|
```ruby
|
|
37
46
|
begin
|
|
38
|
-
client.
|
|
39
|
-
rescue Kit::
|
|
40
|
-
warn e.
|
|
47
|
+
client.subscribers.get(42)
|
|
48
|
+
rescue Kit::NotFoundError => e # 404 — also 401/403/409/413/422 classes
|
|
49
|
+
warn e.message # => "GET /v4/subscribers/42 failed with status 404: ..."
|
|
41
50
|
rescue Kit::RateLimitError => e # 429 — honours Retry-After
|
|
42
51
|
sleep e.retry_after
|
|
43
|
-
rescue Kit::APIError => e # any other non-2xx
|
|
44
|
-
|
|
52
|
+
rescue Kit::APIError => e # any other non-2xx: e.status, e.errors, e.method, e.path
|
|
53
|
+
rescue Kit::TimeoutError, Kit::ConnectionError => e # never got a response (< Kit::TransportError)
|
|
54
|
+
rescue Kit::UnexpectedResponseError => e # 2xx whose body is not the documented shape
|
|
45
55
|
end
|
|
46
56
|
```
|
|
47
57
|
|
|
48
|
-
|
|
58
|
+
A 429 is retried for every request (with `Retry-After`, capped at
|
|
59
|
+
`max_backoff`); 5xx and transport failures are retried only for idempotent
|
|
60
|
+
verbs, so a POST is never replayed. Credentials are masked in `#inspect`.
|
|
49
61
|
|
|
50
62
|
## Resources
|
|
51
63
|
|
|
@@ -56,15 +68,44 @@ click reports), `email_templates`, `segments`, `posts`, `snippets`, `purchases`,
|
|
|
56
68
|
|
|
57
69
|
### Pagination
|
|
58
70
|
|
|
59
|
-
List endpoints return a `Kit::Collection` — `Enumerable` over the current page
|
|
60
|
-
with lazy cursor following:
|
|
71
|
+
List endpoints return a `Kit::Collection` — `Enumerable` over the current page
|
|
72
|
+
(`size`, `empty?`, `[]`), with lazy cursor following:
|
|
61
73
|
|
|
62
74
|
```ruby
|
|
63
75
|
client.subscribers.list.each { |s| ... } # current page
|
|
64
76
|
client.subscribers.list.auto_paging_each { |s| ... } # every page, lazily
|
|
65
|
-
client.subscribers.list(status: "active", per_page: 100)
|
|
77
|
+
page = client.subscribers.list(status: "active", per_page: 100, include_total_count: true)
|
|
78
|
+
page.total_count # => 1234 (first page only, as Kit asks)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Bulk
|
|
82
|
+
|
|
83
|
+
The `bulk` endpoints (OAuth only) return a `Kit::Objects::BulkResult`:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
result = client.bulk.create_tags([{ name: "vip" }, { name: "" }])
|
|
87
|
+
result.async? # true when Kit queued the batch (202) and will POST to callback_url
|
|
88
|
+
result.items # the affected records (raw Hashes; shape varies per endpoint)
|
|
89
|
+
result.failures # => [#<BulkFailure item={"name"=>""} errors=["Name can't be blank"]>]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Receiving webhooks
|
|
93
|
+
|
|
94
|
+
Endpoints created with `client.webhook_endpoints.create` return their signing
|
|
95
|
+
`secret` once. Verify and parse each delivery with it:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
delivery = Kit::Webhooks::Delivery.from_request(
|
|
99
|
+
request.raw_post, request.headers["X-Kit-Signature"], secret: ENV.fetch("KIT_WEBHOOK_SECRET")
|
|
100
|
+
) # raises Kit::Webhooks::SignatureError
|
|
101
|
+
delivery.events.each { |e| handle(e.type, e.data) unless seen?(e.id) }
|
|
102
|
+
|
|
103
|
+
Kit::Webhooks::Events::SUBSCRIBER_TAG_ADDED # => "subscriber.tag_added" (all 28 listed)
|
|
66
104
|
```
|
|
67
105
|
|
|
106
|
+
Signatures are HMAC-SHA256 over `"#{t}.#{raw_body}"` with a 300 s replay window;
|
|
107
|
+
both secrets are accepted during a rotation.
|
|
108
|
+
|
|
68
109
|
### OAuth 2.0
|
|
69
110
|
|
|
70
111
|
```ruby
|
|
@@ -88,14 +129,18 @@ rejects it on the resource endpoints — account access needs the consent flow).
|
|
|
88
129
|
The suite is layered:
|
|
89
130
|
|
|
90
131
|
- **Unit** — every method against WebMock stubs.
|
|
91
|
-
- **Contract** —
|
|
92
|
-
|
|
132
|
+
- **Contract** — every list and single-object operation, every create/update
|
|
133
|
+
request body, and every 204 response is pinned to the vendored OpenAPI
|
|
134
|
+
document, so reading the wrong envelope, sending an undocumented field, or
|
|
135
|
+
parsing a no-content response fails automatically. A weekly workflow diffs
|
|
136
|
+
the vendored document against Kit's and fails on drift.
|
|
93
137
|
- **Integration** — real recorded responses (VCR cassettes, secrets scrubbed)
|
|
94
138
|
replayed in CI, proving the live shapes still parse into our value objects.
|
|
95
139
|
- **Smoke** — `rake smoke` hits every read endpoint live (needs `KIT_API_KEY`).
|
|
96
140
|
- **E2E** — an opt-in (`KIT_E2E=1`) create→update→list→delete lifecycle that
|
|
97
141
|
cleans up after itself.
|
|
98
|
-
- **Types** — full RBS signatures
|
|
142
|
+
- **Types** — full RBS signatures (explicit keywords on every create/update),
|
|
143
|
+
checked with Steep. Line and branch coverage are enforced at 90%.
|
|
99
144
|
|
|
100
145
|
```sh
|
|
101
146
|
bin/setup
|
|
@@ -103,6 +148,11 @@ bundle exec rake # spec + rubocop + steep
|
|
|
103
148
|
bundle exec rake smoke # live read-only smoke (needs a key)
|
|
104
149
|
```
|
|
105
150
|
|
|
151
|
+
`bin/setup` points `core.hooksPath` at `.githooks/`, so a **pre-commit** hook
|
|
152
|
+
runs RuboCop on staged Ruby files and a **pre-push** hook runs the full
|
|
153
|
+
`bundle exec rake` gate. Both rely on exit codes, not parsed output — a red gate
|
|
154
|
+
cannot be committed or pushed.
|
|
155
|
+
|
|
106
156
|
## License
|
|
107
157
|
|
|
108
158
|
MIT.
|
data/docs/DESIGN.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
A modern, fully-typed Ruby client for the **Kit** (formerly ConvertKit) **API v4**.
|
|
4
4
|
The existing Ruby gems all stop at API v3/v2; kit-rb targets v4 to a high bar.
|
|
5
5
|
|
|
6
|
+
> **Status: shipped.** All phases below are complete. v0.2.0 is on RubyGems;
|
|
7
|
+
> the post-0.2.0 hardening pass (`docs/TASKS.md`) completed the surface to all
|
|
8
|
+
> 83 documented operations, each pinned to the vendored OpenAPI document by
|
|
9
|
+
> contract tests.
|
|
10
|
+
|
|
6
11
|
## Locked decisions
|
|
7
12
|
|
|
8
13
|
| area | choice | why |
|
|
@@ -19,7 +24,7 @@ The existing Ruby gems all stop at API v3/v2; kit-rb targets v4 to a high bar.
|
|
|
19
24
|
Kit::Client # entry: picks an auth strategy, holds one Connection
|
|
20
25
|
├─ Kit::Configuration # immutable; validates exactly-one-credential
|
|
21
26
|
├─ Kit::Auth::ApiKey # X-Kit-Api-Key header
|
|
22
|
-
├─ Kit::Auth::OAuth # Bearer; authorize/refresh/PKCE
|
|
27
|
+
├─ Kit::Auth::OAuth # Bearer; authorize/exchange/refresh/PKCE/revoke/client_credentials
|
|
23
28
|
├─ Kit::Connection # http.rb transport, JSON, auth injection, error mapping
|
|
24
29
|
├─ Kit::Error (tree) # typed exceptions mapped from HTTP status
|
|
25
30
|
├─ Kit::Objects::* # Data value objects, `.from(hash)` constructors
|
|
@@ -27,24 +32,43 @@ Kit::Client # entry: picks an auth strategy, holds one Connection
|
|
|
27
32
|
```
|
|
28
33
|
|
|
29
34
|
Facts pinned from the OpenAPI spec (`developers.kit.com/api-reference/v4.json`,
|
|
30
|
-
OpenAPI 3.0.3, "Kit API 4.0", host `https://api.kit.com`, 52 paths
|
|
35
|
+
OpenAPI 3.0.3, "Kit API 4.0", host `https://api.kit.com`, 52 paths / 83 operations,
|
|
36
|
+
vendored to `spec/support/kit-v4.openapi.json`): API-key
|
|
31
37
|
header `X-Kit-Api-Key`; OAuth authorize/token at `/v4/oauth/*`, scopes read/write;
|
|
32
38
|
rate limits 120/60s (key) and 600/60s (OAuth); cursor pagination (`after`/`before`
|
|
33
39
|
+ `per_page`, response carries a `pagination` object).
|
|
34
40
|
|
|
35
|
-
## Phases
|
|
41
|
+
## Phases — all complete ✅
|
|
36
42
|
|
|
37
|
-
- **P0 — foundations
|
|
43
|
+
- **P0 — foundations. ✅** Gem skeleton on the clean `Kit` namespace, gates
|
|
38
44
|
(rspec + rubocop + steep) green in CI (Ruby 3.2–3.4), and a walking-skeleton
|
|
39
45
|
vertical slice: `GET /v4/account` end to end (auth → request → typed error →
|
|
40
46
|
`Data` object) with full spec coverage.
|
|
41
|
-
- **P1 — core, by hand
|
|
42
|
-
|
|
43
|
-
authorization-code grant
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
- **P1 — core, by hand. ✅** Cursor auto-pagination (lazy Enumerator, POST-based
|
|
48
|
+
lists supported), 429/5xx retry with backoff, and the full OAuth suite:
|
|
49
|
+
authorization-code grant, single-use refresh, PKCE (S256), RFC 7009 revocation,
|
|
50
|
+
and the client_credentials grant.
|
|
51
|
+
- **P2 — resources, spec-driven + local models. ✅** Every resource group shipped
|
|
52
|
+
with objects + specs — subscribers, tags, custom fields, forms, sequences (+
|
|
53
|
+
emails), broadcasts (+ stats/clicks), purchases, webhooks, webhook endpoints,
|
|
54
|
+
email templates, segments, posts, snippets, account extras, and bulk. Early
|
|
55
|
+
batches were dogfooded through `forge` against a local model on mbp; that
|
|
56
|
+
surfaced the `Base#one`/`#collection` abstractions, after which the surface was
|
|
57
|
+
completed by hand. 81 operations shipped in 0.2.0; the two PATCH operations
|
|
58
|
+
landed in the hardening pass. **All 83 operations covered**, pinned by the
|
|
59
|
+
contract specs.
|
|
60
|
+
- **P3 — quality. ✅** An OpenAPI list-envelope contract test (each list resource
|
|
61
|
+
pinned to the vendored spec), plus VCR integration cassettes (secrets scrubbed)
|
|
62
|
+
replayed in CI, `rake smoke` (live read-only), and an opt-in e2e lifecycle.
|
|
63
|
+
99%+ line coverage; full RBS checked by Steep.
|
|
64
|
+
- **P4 — DX & release. ✅** README with usage/OAuth/testing docs, `rubygems_mfa_
|
|
65
|
+
required`, and a GitHub Actions **OIDC Trusted Publishing** workflow (tag `v*`
|
|
66
|
+
→ gated release, no stored key). v0.1.0 and v0.2.0 published this way.
|
|
67
|
+
Deferred (optional): a hosted YARD site and cryptographic gem signing.
|
|
68
|
+
- **P5 — hardening (2026-09-06). ✅** A full audit against the OpenAPI document
|
|
69
|
+
and the live docs, tracked row by row in `docs/TASKS.md`: two real-API bugs
|
|
70
|
+
(204 unsubscribe, dropped webhook secret), safety (credential masking, path
|
|
71
|
+
id validation, no POST replay, Retry-After cap), the missing PATCH
|
|
72
|
+
operations and response fields, incoming-webhook verification, typed bulk
|
|
73
|
+
results and transport errors, explicit keyword bodies with tightened RBS,
|
|
74
|
+
and contract tests over object envelopes, request bodies and 204s.
|
data/docs/TASKS.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Tasks
|
|
2
|
+
|
|
3
|
+
Progress tracker for the post-0.2.0 hardening pass. Each row lands as one atomic
|
|
4
|
+
commit with its own specs; the row is ticked in the same commit. Findings come
|
|
5
|
+
from the 2026-09-04 audit of the gem against the vendored OpenAPI document
|
|
6
|
+
(`spec/support/kit-v4.openapi.json`, 52 paths / 83 operations) and the live docs.
|
|
7
|
+
|
|
8
|
+
Legend: `[ ]` todo · `[x]` done (commit noted).
|
|
9
|
+
|
|
10
|
+
## P0 — breaks against the real API
|
|
11
|
+
|
|
12
|
+
- [x] `subscribers.unsubscribe` calls `one(...)` on a 204 no-body response → `NoMethodError`. (`fix: subscribers.unsubscribe handles the 204 no-content response`)
|
|
13
|
+
- [x] `WebhookEndpoint` drops `secret`, the only time Kit returns the signing secret (`create`, `rotate_secret`). (`fix: WebhookEndpoint carries the one-time signing secret`)
|
|
14
|
+
- [x] `Retry-After` on 429 is honoured without the `max_backoff` cap (a 30 s header blocks the caller for 60 s). (`fix: cap Retry-After at max_backoff`)
|
|
15
|
+
- [x] Error-mapping specs really sleep: the 429 example alone costs 60 s per CI run. (`test: stop error-mapping specs from really sleeping`)
|
|
16
|
+
- [x] Unexpected response shapes surface as `KeyError` / `NoMethodError` instead of a `Kit::Error`. (`fix: raise Kit::UnexpectedResponseError on drifted 2xx bodies`)
|
|
17
|
+
- [x] Path ids are interpolated unescaped and unvalidated (`get("1/unsubscribe?x")`, `get(nil)`). (`fix: validate and percent-encode path ids`)
|
|
18
|
+
- [x] Non-idempotent POSTs are retried on 5xx (duplicate creates). (`fix: do not replay POSTs after a 5xx`)
|
|
19
|
+
|
|
20
|
+
## P1 — security / correctness
|
|
21
|
+
|
|
22
|
+
- [x] `Client`, `Configuration`, `Auth::*`, `OAuth::Token` `#inspect` print the credential in plaintext. (`fix: mask credentials in inspect output`)
|
|
23
|
+
- [x] `total_count` is discarded by `Pagination.from`; auto-paging resends `include_total_count` on every page; a `before:` param leaks into `after:` follow-ups. (`feat: surface total_count and fix follow-up page params`)
|
|
24
|
+
- [x] No error classes for 409 (rotate_secret conflict) and 413 (bulk quota). (`feat: typed errors for 409 and 413`)
|
|
25
|
+
- [x] 202 (async bulk) indistinguishable from 200; bulk methods return raw Hashes. (`feat: typed BulkResult with async (202) detection`)
|
|
26
|
+
- [x] Error messages omit the request method and path. (`feat: name the failed request in APIError messages`)
|
|
27
|
+
|
|
28
|
+
## P2 — API coverage
|
|
29
|
+
|
|
30
|
+
- [x] `PATCH /v4/subscribers/{id}/location` (`subscribers.update_location`). (`feat: subscribers.update_location and stats date window`)
|
|
31
|
+
- [x] `PATCH /v4/webhook_endpoints/{id}` (`webhook_endpoints.update`). (`feat: webhook_endpoints.update`)
|
|
32
|
+
- [x] `sequences.get` / `sequences.email` cannot send `include=stats`; `subscribers.stats` cannot send `email_sent_after/before`. (`feat: include=stats on sequences.get and sequences.email`, `feat: subscribers.update_location and stats date window`)
|
|
33
|
+
- [x] Missing response fields: `Account` (`timezone`, `plan`, `sending_addresses`), `Post#content`, `SequenceEmail#content`, `Subscriber` (`added_at`, `referrer`, `referrer_utm_parameters`, `tagged_at`, `attribution`, `tags`), `Tag#tagged_at`, `CustomField#created_at`. (`feat: complete the value objects against the spec's response schemas`)
|
|
34
|
+
- [x] Incoming webhooks: `X-Kit-Signature` HMAC-SHA256 verification and delivery-envelope parsing. (`feat: verify and parse incoming webhook deliveries`)
|
|
35
|
+
- [x] Webhook event-name constants. (`feat: webhook event-name constants`)
|
|
36
|
+
|
|
37
|
+
## P3 — engineering quality / DX
|
|
38
|
+
|
|
39
|
+
- [x] `Collection` lacks `size`, `empty?`, `[]`, and a readable `inspect`. (`feat: Collection#size, #empty?, #[] and a readable inspect`)
|
|
40
|
+
- [x] Transport errors (timeouts, connection refused) collapse into the generic `Kit::Error`. (`feat: typed transport errors, retried for idempotent requests`)
|
|
41
|
+
- [x] One `HTTP::Client` per request: no persistent connections. Client is now built once per Connection; true keep-alive (`HTTP.persistent`) is deliberately not used because it is not thread-safe while `Kit::Client` is documented as shareable. (`perf: build the http.rb client once per Connection`)
|
|
42
|
+
- [x] Stale P0/P1 comments in `auth/oauth.rb`, `resources/account.rb`, `sig/kit-rb.rbs`; unused `Auth::OAuth::*_URL` constants; template comments in the gemspec. (`chore: drop stale phase comments, dead constants, and gemspec boilerplate`)
|
|
43
|
+
- [x] README / DESIGN.md claim 81 operations; the spec has 83. (`docs: document the hardening pass in README, DESIGN and CHANGELOG`)
|
|
44
|
+
- [x] CI matrix lacks Ruby 3.5; no Dependabot; no branch coverage. (`ci: test on Ruby 3.5, add Dependabot, enforce branch coverage`; then `ci: run Ruby head as an allowed-to-fail job` — '3.5' resolved to 3.5.0-preview1 where ffi does not build, so the next Ruby is tracked via `head` and cannot fail the run)
|
|
45
|
+
- [x] RBS: 22 methods take `**untyped`; tighten the fixed-key bodies (broadcasts, sequences, sequence emails, snippets, purchases). List query params stay `**params`. (`refactor: explicit keyword bodies for create/update methods`)
|
|
46
|
+
- [x] OpenAPI contract test covers only list envelopes; extend to single-object envelopes and request bodies. (`test: contract-check object envelopes, request bodies, and 204s against the spec`)
|
|
47
|
+
- [x] `rake contract:fetch` is manual; add a scheduled drift check. (`ci: weekly OpenAPI drift check`)
|
data/lib/kit/auth/api_key.rb
CHANGED
|
@@ -19,6 +19,13 @@ module Kit
|
|
|
19
19
|
def headers
|
|
20
20
|
{ HEADER => @key }
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
# Never print the key: a client or config that ends up in a log line or an
|
|
24
|
+
# exception message must not leak the credential.
|
|
25
|
+
def inspect
|
|
26
|
+
"#<#{self.class.name} key=#{Credential.mask(@key)}>"
|
|
27
|
+
end
|
|
28
|
+
alias to_s inspect
|
|
22
29
|
end
|
|
23
30
|
end
|
|
24
31
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kit
|
|
4
|
+
module Auth
|
|
5
|
+
# Renders a secret for #inspect output: the last four characters behind a
|
|
6
|
+
# fixed-width mask, so two clients can be told apart in a log without the
|
|
7
|
+
# log ever containing a usable credential. Short secrets are fully masked.
|
|
8
|
+
module Credential
|
|
9
|
+
MASK = "****"
|
|
10
|
+
VISIBLE = 4
|
|
11
|
+
|
|
12
|
+
def self.mask(secret)
|
|
13
|
+
return "nil" if secret.nil?
|
|
14
|
+
|
|
15
|
+
value = secret.to_s
|
|
16
|
+
return MASK if value.length <= VISIBLE * 2
|
|
17
|
+
|
|
18
|
+
"#{MASK}#{value[-VISIBLE..]}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/kit/auth/oauth.rb
CHANGED
|
@@ -5,15 +5,9 @@ module Kit
|
|
|
5
5
|
# OAuth 2.0 bearer-token authentication: sends `Authorization: Bearer <token>`.
|
|
6
6
|
#
|
|
7
7
|
# Rate limit is 600 requests / 60s, and OAuth is required for the bulk and
|
|
8
|
-
# purchase-creation endpoints.
|
|
9
|
-
#
|
|
10
|
-
# P0 scope: carry an already-obtained access token. The authorization-code
|
|
11
|
-
# grant (authorize/token URLs at /v4/oauth/*), refresh, and PKCE helper land
|
|
12
|
-
# in P1 — this class is the seam they plug into.
|
|
8
|
+
# purchase-creation endpoints. Obtaining and refreshing tokens is
|
|
9
|
+
# Kit::OAuth::Client's job; this class only carries an access token.
|
|
13
10
|
class OAuth
|
|
14
|
-
AUTHORIZE_URL = "https://api.kit.com/v4/oauth/authorize"
|
|
15
|
-
TOKEN_URL = "https://api.kit.com/v4/oauth/token"
|
|
16
|
-
|
|
17
11
|
def initialize(access_token)
|
|
18
12
|
raise ConfigurationError, "OAuth access token cannot be blank" if access_token.nil? || access_token.empty?
|
|
19
13
|
|
|
@@ -23,6 +17,11 @@ module Kit
|
|
|
23
17
|
def headers
|
|
24
18
|
{ "Authorization" => "Bearer #{@access_token}" }
|
|
25
19
|
end
|
|
20
|
+
|
|
21
|
+
def inspect
|
|
22
|
+
"#<#{self.class.name} access_token=#{Credential.mask(@access_token)}>"
|
|
23
|
+
end
|
|
24
|
+
alias to_s inspect
|
|
26
25
|
end
|
|
27
26
|
end
|
|
28
27
|
end
|
data/lib/kit/configuration.rb
CHANGED
|
@@ -4,16 +4,17 @@ 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, :write_timeout,
|
|
8
8
|
:max_retries, :retry_backoff, :max_backoff
|
|
9
9
|
|
|
10
10
|
def initialize(api_key: nil, access_token: nil, base_url: DEFAULT_BASE_URL,
|
|
11
|
-
open_timeout: 10, read_timeout: 30,
|
|
11
|
+
open_timeout: 10, read_timeout: 30, write_timeout: 30,
|
|
12
12
|
max_retries: 2, retry_backoff: 0.5, max_backoff: 30)
|
|
13
13
|
@auth = build_auth(api_key, access_token)
|
|
14
14
|
@base_url = base_url
|
|
15
15
|
@open_timeout = open_timeout
|
|
16
16
|
@read_timeout = read_timeout
|
|
17
|
+
@write_timeout = write_timeout # http 5 would otherwise default to 0.25 s
|
|
17
18
|
@max_retries = max_retries # 0 disables automatic retries
|
|
18
19
|
@retry_backoff = retry_backoff # base seconds for exponential backoff
|
|
19
20
|
@max_backoff = max_backoff # cap on any single sleep
|
data/lib/kit/connection.rb
CHANGED
|
@@ -10,45 +10,74 @@ module Kit
|
|
|
10
10
|
# Resources talk to the API only through this.
|
|
11
11
|
class Connection
|
|
12
12
|
JSON_TYPE = "application/json"
|
|
13
|
-
RETRYABLE = [RateLimitError, ServerError].freeze
|
|
13
|
+
RETRYABLE = [RateLimitError, ServerError, TransportError].freeze
|
|
14
|
+
# Verbs safe to replay after a 5xx: the server may have applied the request
|
|
15
|
+
# before failing, and replaying these cannot create a second resource.
|
|
16
|
+
IDEMPOTENT = %i[get head put delete].freeze
|
|
14
17
|
|
|
15
18
|
def initialize(config)
|
|
16
19
|
@config = config
|
|
20
|
+
# Built once: an http.rb client is an immutable options chain, safe to
|
|
21
|
+
# share across threads, and each request still opens its own socket.
|
|
22
|
+
# (HTTP.persistent would keep one socket alive but is not thread-safe,
|
|
23
|
+
# and a Client is documented as shareable — so it is not used.)
|
|
24
|
+
@client = HTTP
|
|
25
|
+
.headers(default_headers)
|
|
26
|
+
.timeout(connect: @config.open_timeout, read: @config.read_timeout, write: @config.write_timeout)
|
|
17
27
|
end
|
|
18
28
|
|
|
19
29
|
# Issues a request and returns the parsed JSON body (a Hash) on success.
|
|
20
30
|
# 429s honour `Retry-After`; 5xx use exponential backoff with jitter; both
|
|
21
31
|
# give up after `config.max_retries` and re-raise the typed error.
|
|
22
32
|
#
|
|
33
|
+
# A 429 is retried for every verb (Kit rejected the request, so nothing was
|
|
34
|
+
# applied). A 5xx or a transport failure (timeout, dropped connection) is
|
|
35
|
+
# retried only for idempotent verbs: a POST that created a subscriber
|
|
36
|
+
# before the gateway failed would be created twice.
|
|
37
|
+
#
|
|
23
38
|
# @param method [Symbol] :get, :post, :put, :delete
|
|
24
39
|
# @param path [String] e.g. "/v4/account" (leading slash, no host)
|
|
25
40
|
# @param params [Hash] query string params
|
|
26
41
|
# @param body [Hash, nil] JSON request body
|
|
27
42
|
def request(method, path, params: {}, body: nil)
|
|
43
|
+
request_with_status(method, path, params: params, body: body).last
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# As #request, but returns [status, body] for the callers that must tell a
|
|
47
|
+
# 200 (applied now) from a 202 (queued; the bulk endpoints).
|
|
48
|
+
def request_with_status(method, path, params: {}, body: nil)
|
|
28
49
|
attempt = 0
|
|
29
50
|
begin
|
|
30
|
-
handle(perform(method, path, params, body))
|
|
51
|
+
handle(perform(method, path, params, body), method, path)
|
|
31
52
|
rescue *RETRYABLE => e
|
|
32
53
|
attempt += 1
|
|
33
|
-
raise if attempt > @config.max_retries
|
|
54
|
+
raise if attempt > @config.max_retries || !retryable?(method, e)
|
|
34
55
|
|
|
35
56
|
backoff_sleep(backoff_for(e, attempt))
|
|
36
57
|
retry
|
|
37
58
|
end
|
|
38
59
|
end
|
|
39
60
|
|
|
61
|
+
# The memoised http.rb client carries the auth header, so the default
|
|
62
|
+
# inspect would print the credential.
|
|
63
|
+
def inspect
|
|
64
|
+
"#<#{self.class.name} base_url=#{@config.base_url.inspect} auth=#{@config.auth.inspect}>"
|
|
65
|
+
end
|
|
66
|
+
|
|
40
67
|
private
|
|
41
68
|
|
|
42
|
-
def
|
|
43
|
-
|
|
44
|
-
rescue HTTP::Error => e
|
|
45
|
-
raise Error, "HTTP transport error: #{e.message}"
|
|
69
|
+
def retryable?(method, error)
|
|
70
|
+
error.is_a?(RateLimitError) || IDEMPOTENT.include?(method)
|
|
46
71
|
end
|
|
47
72
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
73
|
+
def perform(method, path, params, body)
|
|
74
|
+
@client.request(method, "#{@config.base_url}#{path}", params: params, json: body)
|
|
75
|
+
rescue HTTP::TimeoutError => e
|
|
76
|
+
raise TimeoutError, "#{method.to_s.upcase} #{path} timed out: #{e.message}"
|
|
77
|
+
rescue HTTP::ConnectionError => e
|
|
78
|
+
raise ConnectionError, "#{method.to_s.upcase} #{path} could not connect: #{e.message}"
|
|
79
|
+
rescue HTTP::Error => e
|
|
80
|
+
raise TransportError, "#{method.to_s.upcase} #{path} failed in transport: #{e.message}"
|
|
52
81
|
end
|
|
53
82
|
|
|
54
83
|
def default_headers
|
|
@@ -59,12 +88,12 @@ module Kit
|
|
|
59
88
|
}.merge(@config.auth.headers)
|
|
60
89
|
end
|
|
61
90
|
|
|
62
|
-
def handle(response)
|
|
91
|
+
def handle(response, method, path)
|
|
63
92
|
status = response.status.to_i
|
|
64
93
|
parsed = parse(response)
|
|
65
|
-
return parsed if (200..299).cover?(status)
|
|
94
|
+
return [status, parsed] if (200..299).cover?(status)
|
|
66
95
|
|
|
67
|
-
raise error_for(status, parsed, response)
|
|
96
|
+
raise error_for(status, parsed, response, method, path)
|
|
68
97
|
end
|
|
69
98
|
|
|
70
99
|
def parse(response)
|
|
@@ -76,21 +105,25 @@ module Kit
|
|
|
76
105
|
raw
|
|
77
106
|
end
|
|
78
107
|
|
|
79
|
-
def error_for(status, body, response)
|
|
108
|
+
def error_for(status, body, response, method, path)
|
|
80
109
|
klass = Error.class_for(status)
|
|
81
110
|
if klass == RateLimitError
|
|
82
|
-
klass.new(status: status, body: body, response: response,
|
|
111
|
+
klass.new(status: status, body: body, response: response, method: method, path: path,
|
|
83
112
|
retry_after: response.headers["Retry-After"]&.to_i)
|
|
84
113
|
else
|
|
85
|
-
klass.new(status: status, body: body, response: response)
|
|
114
|
+
klass.new(status: status, body: body, response: response, method: method, path: path)
|
|
86
115
|
end
|
|
87
116
|
end
|
|
88
117
|
|
|
89
118
|
# 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
|
|
91
|
-
# capped at config.max_backoff
|
|
119
|
+
# sent a usable one (429), else exponential backoff (base * 2^(n-1)) with
|
|
120
|
+
# jitter. Both are capped at config.max_backoff — a Retry-After of 300 must
|
|
121
|
+
# not block the caller for five minutes; past the cap the typed error is
|
|
122
|
+
# raised and the caller decides. A Retry-After that parses to 0 (an
|
|
123
|
+
# HTTP-date, or garbage) falls through to the exponential schedule.
|
|
92
124
|
def backoff_for(error, attempt)
|
|
93
|
-
|
|
125
|
+
retry_after = error.retry_after if error.is_a?(RateLimitError)
|
|
126
|
+
return [retry_after, @config.max_backoff].min if retry_after&.positive?
|
|
94
127
|
|
|
95
128
|
base = @config.retry_backoff * (2**(attempt - 1))
|
|
96
129
|
[base + (rand * @config.retry_backoff), @config.max_backoff].min
|