kit-rb 0.4.0 → 0.5.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 +38 -1
- data/README.md +79 -1
- data/Rakefile +9 -0
- data/lib/kit/configuration.rb +8 -2
- data/lib/kit/connection.rb +25 -23
- data/lib/kit/instrumentation.rb +136 -0
- data/lib/kit/resources/subscribers.rb +1 -4
- data/lib/kit/resources/tags.rb +5 -15
- data/lib/kit/tag_names.rb +29 -0
- data/lib/kit/testing/factories.rb +124 -0
- data/lib/kit/testing/fixtures.json +2947 -0
- data/lib/kit/testing/fixtures.rb +96 -0
- data/lib/kit/testing/operations.rb +132 -0
- data/lib/kit/testing/stubs.rb +145 -0
- data/lib/kit/testing.rb +185 -0
- data/lib/kit/version.rb +1 -1
- data/lib/kit-rb.rb +2 -0
- data/sig/kit-rb.rbs +125 -2
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8a032593d0a6e493c45d6d0bb84bd540b0dc7e61fd596ed1b4048b1b7d44d66
|
|
4
|
+
data.tar.gz: 2e0616764a8ca5b0f9fb64114dd5d2d0c2c5141fa2a5e057c1a4d39d423a8e90
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8de55860a7da6d89e4488d94999e106d291bcfde45e3523b740d25b1861b78b7eccea1de82280b80b22288d3cdb7447185dc5b94f45add55a44447bc5f998bba
|
|
7
|
+
data.tar.gz: 5b80309f71b11d1c74ec5b7d1332c910d10fab5a7690608bb78f0dfa564247d148c1a0a67e78e8fcf2b2adad889248157958dbf07116f445ed3619ef11287151
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,42 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.5.0] - 2026-09-09
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `require "kit/testing"`: envelope builders for consumer specs, built from
|
|
13
|
+
Kit's documented example responses. `Kit::Testing.response(operation,
|
|
14
|
+
http_status:, **overrides)` for any of the 83 operations (`Kit::Testing::OPERATIONS`,
|
|
15
|
+
named `<resource>_<method>`), `attributes` (the bare object), `list_json`
|
|
16
|
+
(a cursor page from rows or a count), `pagination_json`, `error_json`, and
|
|
17
|
+
one `<object>_json` per envelope (`subscriber_json`, `tag_json`,
|
|
18
|
+
`account_json`, ...). Overrides must be fields Kit documents for that
|
|
19
|
+
envelope. Fixtures are generated by `rake testing:fixtures` from the vendored
|
|
20
|
+
OpenAPI document and pinned by a contract test. Not loaded by `kit-rb`. (#14)
|
|
21
|
+
- `Kit::Testing` typed factories: one `<object>` per envelope builder
|
|
22
|
+
(`Kit::Testing.subscriber(id: 1)` → `Kit::Objects::Subscriber`, `tag`,
|
|
23
|
+
`account`, ...), parsed by the same `Kit::Objects` class the resource uses,
|
|
24
|
+
plus `account_info` (the whole GET /v4/account result), `oauth_token`
|
|
25
|
+
(`Kit::OAuth::Token` with a working 48 h expiry) and `tagged_subscriber`
|
|
26
|
+
(`upsert_and_tag`'s result). Overrides are validated like the builders'.
|
|
27
|
+
Loading `kit/testing` no longer needs http.rb. (#14)
|
|
28
|
+
- `Kit::Testing.stub(operation, http_status:, **path_params_and_overrides)`,
|
|
29
|
+
`stub_error(operation, status, *messages)`, `stub_rate_limited(operation,
|
|
30
|
+
retry_after:)` and `url_for(operation, **path_params)`: WebMock stubs by
|
|
31
|
+
operation name, built from the same documented examples — path params by
|
|
32
|
+
name, percent-encoded exactly as the client sends them (one that is also a
|
|
33
|
+
response field, like `id:`, shapes the body too; on a list, every row),
|
|
34
|
+
unspecified ones and any query string match, `http_status:` selects the
|
|
35
|
+
status-specific documented body, 204 operations answer with no body. WebMock is required by the consumer, not by this gem; a clear
|
|
36
|
+
`ConfigurationError` says so when it is absent. (#14)
|
|
37
|
+
- `instrumenter:` and `logger:` on `Kit::Client` / `Kit::Configuration`. The
|
|
38
|
+
instrumenter receives one `"request.kit"` event per HTTP attempt through
|
|
39
|
+
`instrument(name, payload) { }` (`ActiveSupport::Notifications` compatible,
|
|
40
|
+
no Rails dependency) with `method`, `path`, `status`, `duration`, `retries`,
|
|
41
|
+
`retry_after` and `error`; never the query string, body or a header value.
|
|
42
|
+
`logger:` writes the same payload as one debug line per attempt. Both may be
|
|
43
|
+
given. (#13)
|
|
44
|
+
|
|
9
45
|
## [0.4.0] - 2026-09-09
|
|
10
46
|
|
|
11
47
|
### Added
|
|
@@ -199,7 +235,8 @@ vertical slice. Not yet feature-complete — resources land in 0.1.0 per docs/DE
|
|
|
199
235
|
429/5xx retry with backoff.
|
|
200
236
|
- RBS signatures for the public surface.
|
|
201
237
|
|
|
202
|
-
[Unreleased]: https://github.com/linyiru/kit-rb/compare/v0.
|
|
238
|
+
[Unreleased]: https://github.com/linyiru/kit-rb/compare/v0.5.0...HEAD
|
|
239
|
+
[0.5.0]: https://github.com/linyiru/kit-rb/compare/v0.4.0...v0.5.0
|
|
203
240
|
[0.4.0]: https://github.com/linyiru/kit-rb/compare/v0.3.1...v0.4.0
|
|
204
241
|
[0.3.1]: https://github.com/linyiru/kit-rb/compare/v0.3.0...v0.3.1
|
|
205
242
|
[0.3.0]: https://github.com/linyiru/kit-rb/compare/v0.2.0...v0.3.0
|
data/README.md
CHANGED
|
@@ -75,6 +75,33 @@ A 429 is retried for every request (with `Retry-After`, capped at
|
|
|
75
75
|
retried only for idempotent verbs, so a POST is never replayed after those.
|
|
76
76
|
Credentials are masked in `#inspect`.
|
|
77
77
|
|
|
78
|
+
### Instrumentation
|
|
79
|
+
|
|
80
|
+
Pass `instrumenter:` to observe every HTTP attempt (Sentry breadcrumbs,
|
|
81
|
+
per-request timing), or `logger:` for one debug line each:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
client = Kit::Client.new(api_key: key, instrumenter: ActiveSupport::Notifications)
|
|
85
|
+
ActiveSupport::Notifications.subscribe("request.kit") do |*, payload|
|
|
86
|
+
Sentry.add_breadcrumb(Sentry::Breadcrumb.new(category: "kit", data: payload))
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
client = Kit::Client.new(api_key: key, logger: Rails.logger)
|
|
90
|
+
# D, kit GET /v4/account status=429 duration=0.212s retries=0 retry_after=7 error=Kit::RateLimitError
|
|
91
|
+
# D, kit GET /v4/account status=200 duration=0.180s retries=1
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The interface is `ActiveSupport::Notifications`' (`instrument(name, payload) { }`)
|
|
95
|
+
but the gem has no Rails dependency: any object with that method works. One
|
|
96
|
+
`"request.kit"` event is emitted per attempt, so a retried request shows each
|
|
97
|
+
attempt, with `method`, `path`, `status` (nil when no response arrived),
|
|
98
|
+
`duration` (seconds), `retries`, `retry_after` (on a 429) and `error` (the Kit
|
|
99
|
+
error class name). The payload never contains the query string, the body, or
|
|
100
|
+
any header value, so no credential or subscriber email can reach a log — and a
|
|
101
|
+
failed attempt is reported through `error`, not by raising inside the
|
|
102
|
+
instrumenter, so `ActiveSupport::Notifications` never attaches the exception
|
|
103
|
+
(with its response body) to the payload.
|
|
104
|
+
|
|
78
105
|
### Background jobs
|
|
79
106
|
|
|
80
107
|
The built-in retry sleeps on the calling thread, which is right for a script
|
|
@@ -284,7 +311,58 @@ produces. A transport error means no response was received, not that the request
|
|
|
284
311
|
was not processed: a timed-out `refresh` may already have consumed the refresh
|
|
285
312
|
token (documented as single-use), so treat retrying it as your own decision.
|
|
286
313
|
|
|
287
|
-
## Testing
|
|
314
|
+
## Testing your integration
|
|
315
|
+
|
|
316
|
+
`require "kit/testing"` (not loaded by `kit-rb` itself; no test-framework
|
|
317
|
+
dependency) builds response bodies from Kit's own documented examples, so a
|
|
318
|
+
consumer spec reads as "the upsert succeeds" instead of a hand-written envelope
|
|
319
|
+
that drifts when a field changes:
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
require "kit/testing"
|
|
323
|
+
|
|
324
|
+
Kit::Testing.subscriber_json(id: 500, email_address: "ada@example.com")
|
|
325
|
+
# => { "subscriber" => { "id" => 500, "email_address" => "ada@example.com", "state" => "active", ... } }
|
|
326
|
+
Kit::Testing.tag_json(name: "vip") # { "tag" => {...} }
|
|
327
|
+
Kit::Testing.account_json(plan_type: "free", name: "") # { "user" => {...}, "account" => {...} }
|
|
328
|
+
Kit::Testing.error_json("The API key is invalid") # { "errors" => [...] }
|
|
329
|
+
|
|
330
|
+
Kit::Testing.response(:tags_create, http_status: 201, name: "vip") # any operation, by name
|
|
331
|
+
Kit::Testing.list_json(:subscribers_list, [{ id: 1 }, { id: 2 }], has_next_page: true, end_cursor: "E")
|
|
332
|
+
Kit::Testing.attributes(:subscribers_get, first_name: "Ada") # the bare object, for Objects::Subscriber.from
|
|
333
|
+
|
|
334
|
+
# With WebMock loaded, stub a whole operation by name (path params by name — one
|
|
335
|
+
# that is also a response field, like id:, shapes the body too; any query string
|
|
336
|
+
# matches; returns WebMock's stub for .with / have_been_requested):
|
|
337
|
+
Kit::Testing.stub(:subscribers_create, id: 500)
|
|
338
|
+
Kit::Testing.stub(:tags_tag_subscriber, tag_id: 7, id: 500)
|
|
339
|
+
Kit::Testing.stub(:tags_list, items: [{ name: "vip" }], has_next_page: true)
|
|
340
|
+
Kit::Testing.stub_error(:account_get, 401, "The API key is invalid")
|
|
341
|
+
Kit::Testing.stub_rate_limited(:subscribers_create, retry_after: 7)
|
|
342
|
+
expect(a_request(:post, Kit::Testing.url_for(:tags_tag_subscriber, tag_id: 7, id: 500))).to have_been_made
|
|
343
|
+
|
|
344
|
+
# Typed values for instance_double returns and unit tests — the same examples,
|
|
345
|
+
# parsed by the same Kit::Objects classes the client uses:
|
|
346
|
+
Kit::Testing.subscriber(id: 500, email_address: "ada@example.com") # => Kit::Objects::Subscriber
|
|
347
|
+
Kit::Testing.account_info(plan_type: "free", user: { email: "owner@example.com" })
|
|
348
|
+
Kit::Testing.oauth_token(created_at: Time.now.to_i) # => Kit::OAuth::Token, 48 h expiry
|
|
349
|
+
Kit::Testing.tagged_subscriber(tag_names: ["vip", "beta"]) # upsert_and_tag's result
|
|
350
|
+
allow(kit).to receive(:subscribers).and_return(instance_double(Kit::Resources::Subscribers, get: Kit::Testing.subscriber))
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Operations are named `<resource>_<method>` after the client method
|
|
354
|
+
(`Kit::Testing::OPERATIONS`, 83 of them); `http_status:` picks among the 2xx
|
|
355
|
+
codes Kit documents for one (`status:` stays free for the field of that name on
|
|
356
|
+
posts, purchases, broadcasts and webhook endpoints). An override that is not a documented field of the
|
|
357
|
+
response type (`Kit::Testing::TYPES` disambiguates envelopes such as `"stats"`
|
|
358
|
+
that wrap different objects) raises `ArgumentError`, so a typo — or a field of
|
|
359
|
+
the wrong object — cannot build a response the real API would never send. The fixtures are generated from the vendored OpenAPI
|
|
360
|
+
document's examples (`rake testing:fixtures`) and a contract test fails when
|
|
361
|
+
they, the operation registry, or the document drift apart. `require "kit/testing"` loads only the
|
|
362
|
+
value objects, not http.rb, so it works wherever the gem's runtime dependencies
|
|
363
|
+
are absent.
|
|
364
|
+
|
|
365
|
+
## Testing this gem
|
|
288
366
|
|
|
289
367
|
The suite is layered:
|
|
290
368
|
|
data/Rakefile
CHANGED
|
@@ -25,6 +25,15 @@ namespace :contract do
|
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
namespace :testing do
|
|
29
|
+
desc "Regenerate lib/kit/testing/fixtures.json from the registry + OpenAPI examples"
|
|
30
|
+
task :fixtures do
|
|
31
|
+
require_relative "spec/support/testing_fixtures"
|
|
32
|
+
TestingFixtures.write
|
|
33
|
+
puts "Wrote #{TestingFixtures::PATH} (#{Kit::Testing::OPERATIONS.size} operations)"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
28
37
|
desc "Live read-only smoke test against the real API (needs KIT_API_KEY)"
|
|
29
38
|
task :smoke do
|
|
30
39
|
require_relative "spec/support/smoke"
|
data/lib/kit/configuration.rb
CHANGED
|
@@ -12,15 +12,21 @@ module Kit
|
|
|
12
12
|
# differs from the token it was given, another process refreshed first and
|
|
13
13
|
# it should return the persisted token without calling Kit. Errors it raises
|
|
14
14
|
# propagate untouched. A 403 is never renewed: that is scope, not expiry.
|
|
15
|
+
#
|
|
16
|
+
# `instrumenter:` receives one "request.kit" event per HTTP attempt through
|
|
17
|
+
# `instrument(name, payload) { }` (ActiveSupport::Notifications compatible);
|
|
18
|
+
# `logger:` logs the same payload at debug level. See Kit::Instrumentation.
|
|
15
19
|
class Configuration
|
|
16
20
|
attr_reader :auth, :base_url, :open_timeout, :read_timeout, :write_timeout,
|
|
17
|
-
:max_retries, :retry_backoff, :max_backoff, :renew
|
|
21
|
+
:max_retries, :retry_backoff, :max_backoff, :renew, :instrumenter
|
|
18
22
|
|
|
19
23
|
def initialize(api_key: nil, access_token: nil, base_url: DEFAULT_BASE_URL,
|
|
20
24
|
open_timeout: 10, read_timeout: 30, write_timeout: 30,
|
|
21
|
-
max_retries: 2, retry_backoff: 0.5, max_backoff: 30, renew: nil
|
|
25
|
+
max_retries: 2, retry_backoff: 0.5, max_backoff: 30, renew: nil,
|
|
26
|
+
instrumenter: nil, logger: nil)
|
|
22
27
|
@auth = build_auth(api_key, access_token)
|
|
23
28
|
@renew = validate_renew(renew)
|
|
29
|
+
@instrumenter = Instrumentation.build(instrumenter: instrumenter, logger: logger)
|
|
24
30
|
@base_url = base_url
|
|
25
31
|
@open_timeout = open_timeout
|
|
26
32
|
@read_timeout = read_timeout
|
data/lib/kit/connection.rb
CHANGED
|
@@ -56,8 +56,10 @@ module Kit
|
|
|
56
56
|
attempt = 0
|
|
57
57
|
renewed = false
|
|
58
58
|
begin
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
# Snapshot the token this attempt is sent with (renewal on), so a 401 is
|
|
60
|
+
# attributed to it rather than to whatever another thread installs since.
|
|
61
|
+
used = @config.renew && @config.auth.access_token
|
|
62
|
+
attempt_request(method, path, params, body, token: used, attempt: attempt, renewed: renewed)
|
|
61
63
|
rescue *RETRYABLE => e
|
|
62
64
|
attempt += 1
|
|
63
65
|
raise if attempt > @config.max_retries || !retryable?(method, e)
|
|
@@ -84,20 +86,12 @@ module Kit
|
|
|
84
86
|
error.is_a?(RateLimitError) || IDEMPOTENT.include?(method)
|
|
85
87
|
end
|
|
86
88
|
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
# Answers a 401 on `used`. Returns true when the request should be retried:
|
|
95
|
-
# either another thread already installed a newer token (use it, do not
|
|
96
|
-
# renew again), or config.renew returned a replacement. False when there
|
|
97
|
-
# is no callable or it answered nil (cannot renew) — unless a newer token
|
|
98
|
-
# arrived while it was deciding, which is still worth a retry. The
|
|
99
|
-
# replacement is installed compare-and-swap against `used`, so a renewal
|
|
100
|
-
# that completes after a newer token was installed does not roll it back.
|
|
89
|
+
# Answers a 401 on `used`. True when the request should be retried: another
|
|
90
|
+
# thread already installed a newer token (use it, do not renew again), or
|
|
91
|
+
# config.renew returned a replacement. False when there is no callable or
|
|
92
|
+
# it answered nil — unless a newer token arrived meanwhile, still worth a
|
|
93
|
+
# retry. The replacement is installed compare-and-swap against `used`, so
|
|
94
|
+
# a renewal that completes after a newer token cannot roll it back.
|
|
101
95
|
def renew_token?(used)
|
|
102
96
|
renew = @config.renew
|
|
103
97
|
return false unless renew
|
|
@@ -112,8 +106,21 @@ module Kit
|
|
|
112
106
|
true
|
|
113
107
|
end
|
|
114
108
|
|
|
109
|
+
# One HTTP attempt, instrumented (see Instrumentation): perform + handle.
|
|
110
|
+
# `attempt` is the retry budget (429/5xx/transport only); the renewal retry
|
|
111
|
+
# does not consume it but is still an earlier attempt, so it is reported.
|
|
112
|
+
def attempt_request(method, path, params, body, token:, attempt:, renewed:)
|
|
113
|
+
retries = attempt + (renewed ? 1 : 0)
|
|
114
|
+
Instrumentation.around(@config.instrumenter, method, path, retries) do
|
|
115
|
+
handle(perform(method, path, params, body, token), method, path)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The auth header is added per request as a new immutable http.rb chain —
|
|
120
|
+
# for the given token snapshot, or the credential's current header — so a
|
|
121
|
+
# renewed token is used without rebuilding @client.
|
|
115
122
|
def perform(method, path, params, body, token = nil)
|
|
116
|
-
|
|
123
|
+
authed(token).request(method, "#{@config.base_url}#{path}", params: params, json: body)
|
|
117
124
|
rescue HTTP::TimeoutError => e
|
|
118
125
|
raise TimeoutError, "#{method.to_s.upcase} #{path} timed out: #{e.message}"
|
|
119
126
|
rescue HTTP::ConnectionError => e
|
|
@@ -122,12 +129,7 @@ module Kit
|
|
|
122
129
|
raise TransportError, "#{method.to_s.upcase} #{path} failed in transport: #{e.message}"
|
|
123
130
|
end
|
|
124
131
|
|
|
125
|
-
|
|
126
|
-
# so a renewed token is used without rebuilding @client) — for the given
|
|
127
|
-
# OAuth token snapshot, or the credential's current header.
|
|
128
|
-
def authed_client(token = nil)
|
|
129
|
-
@client.headers(token ? @config.auth.headers_for(token) : @config.auth.headers)
|
|
130
|
-
end
|
|
132
|
+
def authed(token) = @client.headers(token ? @config.auth.headers_for(token) : @config.auth.headers)
|
|
131
133
|
|
|
132
134
|
def default_headers
|
|
133
135
|
{
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kit
|
|
4
|
+
# Observability for requests. Configuration#instrumenter receives one event
|
|
5
|
+
# per HTTP round-trip (so a retried request produces one event per attempt):
|
|
6
|
+
#
|
|
7
|
+
# instrumenter.instrument("request.kit", payload) { ... }
|
|
8
|
+
#
|
|
9
|
+
# The interface is ActiveSupport::Notifications' — pass
|
|
10
|
+
# `ActiveSupport::Notifications` itself in a Rails app — but the gem has no
|
|
11
|
+
# Rails dependency: anything responding to `instrument(name, payload) { }`
|
|
12
|
+
# works. The payload is filled in as the request runs (mutated in place, as
|
|
13
|
+
# ActiveSupport does), so subscribers see the final values:
|
|
14
|
+
#
|
|
15
|
+
# method: :get, :post, ...
|
|
16
|
+
# path: "/v4/subscribers/42" — never the query string (it can carry
|
|
17
|
+
# an email address) and never the body
|
|
18
|
+
# status: Integer, or nil when no response was received
|
|
19
|
+
# duration: seconds, Float
|
|
20
|
+
# retries: how many earlier attempts this request has made (0 = first)
|
|
21
|
+
# retry_after: the parsed Retry-After on a 429, else nil
|
|
22
|
+
# error: the Kit error class name when the attempt failed, else nil
|
|
23
|
+
#
|
|
24
|
+
# No header or credential value is ever included; Auth::Credential.mask is
|
|
25
|
+
# for inspect output, not for payloads, which simply omit secrets.
|
|
26
|
+
module Instrumentation
|
|
27
|
+
EVENT = "request.kit"
|
|
28
|
+
|
|
29
|
+
# Runs one HTTP attempt inside an instrumenter event, filling the payload
|
|
30
|
+
# in as it completes (including on failure) and re-raising. `block` returns
|
|
31
|
+
# [status, body]. With no instrumenter this is a plain yield.
|
|
32
|
+
#
|
|
33
|
+
# A failure is captured inside the block and re-raised only after
|
|
34
|
+
# `instrument` has returned: ActiveSupport::Notifications would otherwise
|
|
35
|
+
# rescue it and add `:exception` / `:exception_object` to this very
|
|
36
|
+
# payload — the latter a Kit::APIError still holding the response body —
|
|
37
|
+
# which a subscriber forwarding the payload wholesale would then leak.
|
|
38
|
+
def self.around(instrumenter, method, path, retries)
|
|
39
|
+
return yield unless instrumenter
|
|
40
|
+
|
|
41
|
+
payload = new_payload(method, path, retries)
|
|
42
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
43
|
+
result = failure = nil
|
|
44
|
+
instrumenter.instrument(EVENT, payload) do
|
|
45
|
+
result = yield
|
|
46
|
+
payload[:status] = result.first
|
|
47
|
+
rescue Error => e
|
|
48
|
+
failure = e
|
|
49
|
+
record_error(payload, e)
|
|
50
|
+
ensure
|
|
51
|
+
payload[:duration] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
52
|
+
end
|
|
53
|
+
raise failure if failure
|
|
54
|
+
|
|
55
|
+
# The request's own result, never the instrumenter's return value:
|
|
56
|
+
# observability must not change what a client method returns. nil here
|
|
57
|
+
# means the instrumenter never yielded, so no request was made.
|
|
58
|
+
result || raise(ConfigurationError, "instrumenter did not yield to the request block")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The query string is dropped here, not left to callers: Connection#request
|
|
62
|
+
# is public and a path passed with `?email_address=...` inline must not
|
|
63
|
+
# reach a breadcrumb either.
|
|
64
|
+
def self.new_payload(method, path, retries)
|
|
65
|
+
{ method: method, path: path.split(/[?#]/, 2).first.to_s, status: nil, duration: nil,
|
|
66
|
+
retries: retries, retry_after: nil, error: nil }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.record_error(payload, error)
|
|
70
|
+
payload[:error] = error.class.name
|
|
71
|
+
payload[:status] = error.status if error.is_a?(APIError)
|
|
72
|
+
payload[:retry_after] = error.retry_after if error.is_a?(RateLimitError)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Wraps a plain Logger as an instrumenter: one debug line per attempt.
|
|
76
|
+
# Used for Configuration#logger; you can also pass it as `instrumenter:`.
|
|
77
|
+
class LoggerInstrumenter
|
|
78
|
+
def initialize(logger)
|
|
79
|
+
@logger = logger
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def instrument(_name, payload)
|
|
83
|
+
yield payload
|
|
84
|
+
ensure
|
|
85
|
+
@logger.debug { format_line(payload) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def format_line(payload)
|
|
91
|
+
line = "kit #{payload[:method].to_s.upcase} #{payload[:path]} status=#{payload[:status] || "-"} " \
|
|
92
|
+
"duration=#{format("%.3f", payload[:duration] || 0)}s retries=#{payload[:retries]}"
|
|
93
|
+
line += " retry_after=#{payload[:retry_after]}" if payload[:retry_after]
|
|
94
|
+
line += " error=#{payload[:error]}" if payload[:error]
|
|
95
|
+
line
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Fans one event out to several instrumenters (instrumenter: and logger:
|
|
100
|
+
# given together). Blocks nest so each sees the same payload.
|
|
101
|
+
class Multi
|
|
102
|
+
def initialize(instrumenters)
|
|
103
|
+
@instrumenters = instrumenters
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Yields the payload to the caller's block like every instrumenter does,
|
|
107
|
+
# forwarding whatever each layer yields so nesting stays transparent.
|
|
108
|
+
def instrument(name, payload, &block)
|
|
109
|
+
@instrumenters.reverse.inject(block) do |inner, instrumenter|
|
|
110
|
+
->(yielded) { instrumenter.instrument(name, yielded) { |inner_payload| inner.call(inner_payload) } }
|
|
111
|
+
end.call(payload)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Resolves Configuration's instrumenter:/logger: pair into one instrumenter
|
|
116
|
+
# (or nil when neither is set).
|
|
117
|
+
def self.build(instrumenter: nil, logger: nil)
|
|
118
|
+
list = []
|
|
119
|
+
if instrumenter
|
|
120
|
+
unless instrumenter.respond_to?(:instrument)
|
|
121
|
+
raise ConfigurationError, "instrumenter: must respond to #instrument"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
list << instrumenter
|
|
125
|
+
end
|
|
126
|
+
if logger
|
|
127
|
+
raise ConfigurationError, "logger: must respond to #debug" unless logger.respond_to?(:debug)
|
|
128
|
+
|
|
129
|
+
list << LoggerInstrumenter.new(logger)
|
|
130
|
+
end
|
|
131
|
+
return nil if list.empty?
|
|
132
|
+
|
|
133
|
+
list.size == 1 ? list.first : Multi.new(list)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -107,10 +107,7 @@ module Kit
|
|
|
107
107
|
|
|
108
108
|
private
|
|
109
109
|
|
|
110
|
-
|
|
111
|
-
def distinct_tag_names(tag_names)
|
|
112
|
-
Array(tag_names).map { |name| Tags.normalize_name(name) }.uniq(&:downcase)
|
|
113
|
-
end
|
|
110
|
+
def distinct_tag_names(tag_names) = TagNames.distinct(tag_names)
|
|
114
111
|
|
|
115
112
|
def apply_tag(subscriber, name)
|
|
116
113
|
tag = @tags.ensure(name: name)
|
data/lib/kit/resources/tags.rb
CHANGED
|
@@ -12,22 +12,12 @@ module Kit
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
# Kit matches tag names case-insensitively and v3 consumers carry over a
|
|
15
|
-
# "find_tag_by_name or create" dance that v4 no longer needs.
|
|
16
|
-
# canonical form of a name for that matching
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
# Raises ArgumentError when nothing is left.
|
|
20
|
-
def self.normalize_name(name)
|
|
21
|
-
normalized = name.to_s.gsub(/[[:space:]]+/, " ").strip
|
|
22
|
-
raise ArgumentError, "tag name must not be blank" if normalized.empty?
|
|
15
|
+
# "find_tag_by_name or create" dance that v4 no longer needs. These are
|
|
16
|
+
# the canonical form of a name for that matching and its match key; the
|
|
17
|
+
# rules live in Kit::TagNames so Kit::Testing shares them exactly.
|
|
18
|
+
def self.normalize_name(name) = TagNames.normalize(name)
|
|
23
19
|
|
|
24
|
-
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# The key two names share when Kit would treat them as the same tag.
|
|
28
|
-
def self.name_key(name)
|
|
29
|
-
normalize_name(name).downcase
|
|
30
|
-
end
|
|
20
|
+
def self.name_key(name) = TagNames.key(name)
|
|
31
21
|
|
|
32
22
|
# GET /v4/tags
|
|
33
23
|
def list(**params)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kit
|
|
4
|
+
# How Kit matches tag names, in one dependency-free place so the client
|
|
5
|
+
# (Resources::Tags#ensure, Subscribers#upsert_and_tag) and Kit::Testing's
|
|
6
|
+
# factories agree exactly: runs of Unicode whitespace (`[[:space:]]`, not
|
|
7
|
+
# `\s`, so a fullwidth or typographic space cannot mint a look-alike tag)
|
|
8
|
+
# collapsed to one ASCII space and trimmed; then matched case-insensitively.
|
|
9
|
+
module TagNames
|
|
10
|
+
# The canonical spelling. Raises ArgumentError when nothing is left.
|
|
11
|
+
def self.normalize(name)
|
|
12
|
+
normalized = name.to_s.gsub(/[[:space:]]+/, " ").strip
|
|
13
|
+
raise ArgumentError, "tag name must not be blank" if normalized.empty?
|
|
14
|
+
|
|
15
|
+
normalized
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The key two names share when Kit would treat them as the same tag.
|
|
19
|
+
def self.key(name)
|
|
20
|
+
normalize(name).downcase
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Normalised names, first spelling wins, one per case-insensitive key —
|
|
24
|
+
# the set of tags upsert_and_tag applies for `names`.
|
|
25
|
+
def self.distinct(names)
|
|
26
|
+
Array(names).map { |name| normalize(name) }.uniq(&:downcase)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The value objects are plain Data classes with no dependency on http.rb, so
|
|
4
|
+
# they are loaded here directly: `require "kit/testing"` stays usable without
|
|
5
|
+
# the gem's runtime dependencies installed (the standalone guarantee).
|
|
6
|
+
%w[account creator_profile subscriber tag custom_field sequence sequence_email broadcast broadcast_stats
|
|
7
|
+
post snippet purchase webhook webhook_endpoint email_stats growth_stats subscriber_stats
|
|
8
|
+
tagged_subscriber].each { |object| require_relative "../objects/#{object}" }
|
|
9
|
+
require_relative "../auth/credential"
|
|
10
|
+
require_relative "../oauth/token"
|
|
11
|
+
require_relative "../tag_names"
|
|
12
|
+
|
|
13
|
+
module Kit
|
|
14
|
+
module Testing # rubocop:disable Style/Documentation -- documented in lib/kit/testing.rb
|
|
15
|
+
# Typed value objects for `instance_double` returns and direct unit tests,
|
|
16
|
+
# built from the same documented examples as the envelope builders:
|
|
17
|
+
#
|
|
18
|
+
# Kit::Testing.subscriber(id: 500, email_address: "ada@example.com")
|
|
19
|
+
# # => #<data Kit::Objects::Subscriber id=500, email_address="ada@example.com", state="active", ...>
|
|
20
|
+
# Kit::Testing.account_info(plan_type: "free") # => Kit::Objects::AccountInfo
|
|
21
|
+
# Kit::Testing.oauth_token(expires_in: 3600) # => Kit::OAuth::Token
|
|
22
|
+
# Kit::Testing.tagged_subscriber(tag_names: ["vip", "beta"])
|
|
23
|
+
#
|
|
24
|
+
# Each `<object>` factory is the `<object>_json` builder's envelope object
|
|
25
|
+
# parsed by the Kit::Objects class the resource itself uses, so a factory
|
|
26
|
+
# cannot produce a value the real client could not — the same field
|
|
27
|
+
# validation applies to overrides.
|
|
28
|
+
module Factories
|
|
29
|
+
# <object> factory => the Kit::Objects class that parses its envelope
|
|
30
|
+
# object (the operation comes from Testing::OBJECTS under the same key).
|
|
31
|
+
# `account` is the account object alone; `account_info` below builds the
|
|
32
|
+
# whole GET /v4/account result.
|
|
33
|
+
OBJECT_CLASSES = {
|
|
34
|
+
subscriber: Objects::Subscriber,
|
|
35
|
+
tag: Objects::Tag,
|
|
36
|
+
custom_field: Objects::CustomField,
|
|
37
|
+
sequence: Objects::Sequence,
|
|
38
|
+
sequence_email: Objects::SequenceEmail,
|
|
39
|
+
broadcast: Objects::Broadcast,
|
|
40
|
+
broadcast_stats: Objects::BroadcastStats,
|
|
41
|
+
post: Objects::Post,
|
|
42
|
+
snippet: Objects::Snippet,
|
|
43
|
+
purchase: Objects::Purchase,
|
|
44
|
+
webhook: Objects::Webhook,
|
|
45
|
+
webhook_endpoint: Objects::WebhookEndpoint,
|
|
46
|
+
creator_profile: Objects::CreatorProfile,
|
|
47
|
+
email_stats: Objects::EmailStats,
|
|
48
|
+
growth_stats: Objects::GrowthStats,
|
|
49
|
+
subscriber_stats: Objects::SubscriberStats,
|
|
50
|
+
account: Objects::Account
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# Kit's documented token response, for Kit::OAuth::Token.
|
|
54
|
+
TOKEN_EXAMPLE = {
|
|
55
|
+
"access_token" => "<ACCESS_TOKEN>",
|
|
56
|
+
"refresh_token" => "<REFRESH_TOKEN>",
|
|
57
|
+
"token_type" => "Bearer",
|
|
58
|
+
"expires_in" => 172_800,
|
|
59
|
+
"scope" => "public",
|
|
60
|
+
"created_at" => 1_700_000_000
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# The typed object for `factory` (:subscriber, :tag, ...) with overrides
|
|
64
|
+
# applied to its documented example.
|
|
65
|
+
def object(factory, **overrides)
|
|
66
|
+
klass = OBJECT_CLASSES.fetch(factory.to_sym) do
|
|
67
|
+
raise ArgumentError,
|
|
68
|
+
"unknown Kit::Testing factory #{factory.inspect}; known: #{OBJECT_CLASSES.keys.join(", ")}"
|
|
69
|
+
end
|
|
70
|
+
klass.from(attributes(OBJECTS.fetch(factory.to_sym), **overrides))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The whole GET /v4/account result. Account overrides apply to
|
|
74
|
+
# `account`; pass `user:` to override user fields (validated against the
|
|
75
|
+
# documented user example like every other override).
|
|
76
|
+
def account_info(user: {}, **account_overrides)
|
|
77
|
+
body = account_json(**account_overrides)
|
|
78
|
+
Objects::AccountInfo.new(
|
|
79
|
+
user: Objects::User.from(merge_known("user", body.fetch("user"), user)),
|
|
80
|
+
account: Objects::Account.from(body.fetch("account"))
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A Kit::OAuth::Token from Kit's documented token response. `expires_in`
|
|
85
|
+
# 172800 (48 h) and `created_at` are set, so #expires_at / #expired? /
|
|
86
|
+
# #expiring_within? work; pass `created_at:` to place it in time.
|
|
87
|
+
def oauth_token(**overrides)
|
|
88
|
+
OAuth::Token.from(merge_known("token", TOKEN_EXAMPLE, overrides))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Subscribers#upsert_and_tag's result, shaped as production would: names
|
|
92
|
+
# normalised and de-duplicated the way Kit matches them (Kit::TagNames,
|
|
93
|
+
# the same code the resource runs), one Tag per distinct name with ids
|
|
94
|
+
# ascending from the tag example's, around a subscriber built from the
|
|
95
|
+
# remaining overrides.
|
|
96
|
+
def tagged_subscriber(tag_names: ["vip"], **subscriber_overrides)
|
|
97
|
+
base = object(:tag)
|
|
98
|
+
tags = TagNames.distinct(tag_names).each_with_index.map { |name, i| object(:tag, id: base.id + i, name: name) }
|
|
99
|
+
Objects::TaggedSubscriber.new(subscriber: object(:subscriber, **subscriber_overrides), tags: tags)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# `overrides` over `example`, rejecting a key the documented example does
|
|
105
|
+
# not carry — the same rule the envelope builders apply to their types.
|
|
106
|
+
def merge_known(what, example, overrides)
|
|
107
|
+
unknown = overrides.keys.map(&:to_s) - example.keys
|
|
108
|
+
if unknown.any?
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"#{unknown.first.inspect} is not a field of #{what.inspect}; known: #{example.keys.join(", ")}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
example.merge(overrides.transform_keys(&:to_s))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
extend Factories
|
|
118
|
+
|
|
119
|
+
# Kit::Testing.subscriber(id: 1) etc., one per OBJECTS entry.
|
|
120
|
+
Factories::OBJECT_CLASSES.each_key do |factory|
|
|
121
|
+
define_singleton_method(factory) { |**overrides| object(factory, **overrides) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|