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.
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "set" # rubocop:disable Lint/RedundantRequireStatement -- autoloaded since Ruby 3.2, but be explicit
5
+ require_relative "operations"
6
+
7
+ module Kit
8
+ module Testing
9
+ # The documented example responses Kit::Testing builds from, generated by
10
+ # `rake testing:fixtures` from OPERATIONS and the vendored OpenAPI document
11
+ # (see spec/support/testing_fixtures.rb); a contract test fails when the
12
+ # shipped file differs from what the generator would produce.
13
+ module Fixtures
14
+ PATH = File.expand_path("fixtures.json", __dir__)
15
+
16
+ # { "subscribers_get" => { "verb" => "get", "path" => "/v4/subscribers/{id}",
17
+ # "key" => "subscriber", "kind" => "object" | "list" | "raw" | "none",
18
+ # "type" => "subscriber", "responses" => { "200" => { "subscriber" => {...} } } }, ... }
19
+ # `type` is the logical response type (see Testing::TYPES): the
20
+ # envelope key alone does not identify one — "stats" is three objects.
21
+ # Recursively freezes a parsed JSON tree (Hashes, Arrays, Strings).
22
+ # Done by hand rather than with `JSON.parse(freeze: true)`: that option
23
+ # arrived in json 2.7, and Ruby 3.2's bundled json (2.6) — which is what
24
+ # `ruby --disable-gems` loads — would silently leave the tree mutable.
25
+ def self.deep_freeze(value)
26
+ case value
27
+ when Hash then value.each_value { |v| deep_freeze(v) }
28
+ when Array then value.each { |v| deep_freeze(v) }
29
+ end
30
+ value.freeze
31
+ end
32
+
33
+ # Deep-frozen: a caller reaching in through FIXTURES / .for must not be
34
+ # able to alter a documented example for every later builder call.
35
+ ALL = deep_freeze(JSON.parse(File.read(PATH)))
36
+
37
+ # Response type => every field any documented example of that type
38
+ # shows, so an override the canonical example happens to omit
39
+ # (tagged_at on a subscriber) is still accepted, while a typo — or a
40
+ # field of a different type that shares the envelope key — is not.
41
+ KNOWN_FIELDS = ALL.each_value.with_object(Hash.new { |h, k| h[k] = Set.new }) do |fixture, fields|
42
+ type = fixture["type"]
43
+ next unless type
44
+
45
+ fixture["responses"].each_value do |body|
46
+ value = body && body[fixture["key"]]
47
+ value = value.first if value.is_a?(Array)
48
+ fields[type].merge(value.keys) if value.is_a?(Hash)
49
+ end
50
+ end.transform_values(&:freeze).freeze
51
+
52
+ def self.for(operation)
53
+ ALL.fetch(operation.to_s) do
54
+ raise ArgumentError, "unknown Kit operation #{operation.inspect}; see Kit::Testing::OPERATIONS"
55
+ end
56
+ end
57
+
58
+ # The response code to build: the first Kit documents, or `status` when
59
+ # it is one of them.
60
+ def self.status_for(fixture, status)
61
+ codes = fixture["responses"].keys
62
+ return codes.first if status.nil?
63
+ return status.to_s if codes.include?(status.to_s)
64
+
65
+ raise ArgumentError, "Kit documents #{codes.join(", ")} for this operation, not #{status}"
66
+ end
67
+
68
+ # `overrides` (Symbol or String keys) merged over a copy of `example`
69
+ # (which may be frozen), rejecting a field Kit never documents for this
70
+ # response type. Override values are deep-copied too, so two rows given
71
+ # the same nested Hash stay independent and a frozen value does not
72
+ # leave part of the returned body frozen.
73
+ def self.merge_fields(type, example, overrides)
74
+ overrides.each_with_object(example.dup) do |(field, value), merged|
75
+ name = field.to_s
76
+ unless KNOWN_FIELDS[type].include?(name)
77
+ raise ArgumentError,
78
+ "#{name.inspect} is not a field of #{type.inspect}; known: #{KNOWN_FIELDS[type].sort.join(", ")}"
79
+ end
80
+
81
+ merged[name] = deep_dup(value)
82
+ end
83
+ end
84
+
85
+ # An unfrozen deep copy: what builders hand out, so specs may mutate.
86
+ def self.deep_dup(value)
87
+ case value
88
+ when Hash then value.to_h { |k, v| [k, deep_dup(v)] }
89
+ when Array then value.map { |v| deep_dup(v) }
90
+ when String then value.dup
91
+ else value
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kit
4
+ module Testing
5
+ # Every Kit v4 operation this gem drives, named `<resource>_<method>` after
6
+ # the client method that calls it (`client.subscribers.create` is
7
+ # :subscribers_create). Paths keep the OpenAPI `{placeholder}` form so the
8
+ # registry can be checked one-to-one against the vendored contract; the
9
+ # stub helpers substitute the placeholders.
10
+ #
11
+ # This is the single hand-written source: fixtures.json (example responses)
12
+ # is generated from it plus the OpenAPI document, and a contract test fails
13
+ # when either drifts from the other.
14
+ OPERATIONS = {
15
+ account_get: [:get, "/v4/account"],
16
+ account_colors: [:get, "/v4/account/colors"],
17
+ account_update_colors: [:put, "/v4/account/colors"],
18
+ account_creator_profile: [:get, "/v4/account/creator_profile"],
19
+ account_email_stats: [:get, "/v4/account/email_stats"],
20
+ account_growth_stats: [:get, "/v4/account/growth_stats"],
21
+
22
+ subscribers_list: [:get, "/v4/subscribers"],
23
+ subscribers_create: [:post, "/v4/subscribers"],
24
+ subscribers_filter: [:post, "/v4/subscribers/filter"],
25
+ subscribers_get: [:get, "/v4/subscribers/{id}"],
26
+ subscribers_update: [:put, "/v4/subscribers/{id}"],
27
+ subscribers_unsubscribe: [:post, "/v4/subscribers/{id}/unsubscribe"],
28
+ subscribers_tags: [:get, "/v4/subscribers/{subscriber_id}/tags"],
29
+ subscribers_stats: [:get, "/v4/subscribers/{subscriber_id}/stats"],
30
+ subscribers_set_location: [:post, "/v4/subscribers/{subscriber_id}/location"],
31
+ subscribers_update_location: [:patch, "/v4/subscribers/{subscriber_id}/location"],
32
+ subscribers_remove_location: [:delete, "/v4/subscribers/{subscriber_id}/location"],
33
+
34
+ tags_list: [:get, "/v4/tags"],
35
+ tags_create: [:post, "/v4/tags"],
36
+ tags_update: [:put, "/v4/tags/{id}"],
37
+ tags_subscribers: [:get, "/v4/tags/{tag_id}/subscribers"],
38
+ tags_tag_subscriber: [:post, "/v4/tags/{tag_id}/subscribers/{id}"],
39
+ tags_remove_subscriber: [:delete, "/v4/tags/{tag_id}/subscribers/{id}"],
40
+ tags_tag_subscriber_by_email: [:post, "/v4/tags/{tag_id}/subscribers"],
41
+ tags_remove_subscriber_by_email: [:delete, "/v4/tags/{tag_id}/subscribers"],
42
+
43
+ custom_fields_list: [:get, "/v4/custom_fields"],
44
+ custom_fields_create: [:post, "/v4/custom_fields"],
45
+ custom_fields_update: [:put, "/v4/custom_fields/{id}"],
46
+ custom_fields_delete: [:delete, "/v4/custom_fields/{id}"],
47
+
48
+ forms_list: [:get, "/v4/forms"],
49
+ forms_subscribers: [:get, "/v4/forms/{form_id}/subscribers"],
50
+ forms_add_subscriber: [:post, "/v4/forms/{form_id}/subscribers/{id}"],
51
+ forms_add_subscriber_by_email: [:post, "/v4/forms/{form_id}/subscribers"],
52
+
53
+ sequences_list: [:get, "/v4/sequences"],
54
+ sequences_create: [:post, "/v4/sequences"],
55
+ sequences_get: [:get, "/v4/sequences/{id}"],
56
+ sequences_update: [:put, "/v4/sequences/{id}"],
57
+ sequences_delete: [:delete, "/v4/sequences/{id}"],
58
+ sequences_subscribers: [:get, "/v4/sequences/{sequence_id}/subscribers"],
59
+ sequences_add_subscriber: [:post, "/v4/sequences/{sequence_id}/subscribers/{id}"],
60
+ sequences_add_subscriber_by_email: [:post, "/v4/sequences/{sequence_id}/subscribers"],
61
+ sequences_emails: [:get, "/v4/sequences/{sequence_id}/emails"],
62
+ sequences_create_email: [:post, "/v4/sequences/{sequence_id}/emails"],
63
+ sequences_email: [:get, "/v4/sequences/{sequence_id}/emails/{id}"],
64
+ sequences_update_email: [:put, "/v4/sequences/{sequence_id}/emails/{id}"],
65
+ sequences_delete_email: [:delete, "/v4/sequences/{sequence_id}/emails/{id}"],
66
+
67
+ broadcasts_list: [:get, "/v4/broadcasts"],
68
+ broadcasts_create: [:post, "/v4/broadcasts"],
69
+ broadcasts_get: [:get, "/v4/broadcasts/{id}"],
70
+ broadcasts_update: [:put, "/v4/broadcasts/{id}"],
71
+ broadcasts_delete: [:delete, "/v4/broadcasts/{id}"],
72
+ broadcasts_stats_list: [:get, "/v4/broadcasts/stats"],
73
+ broadcasts_stats: [:get, "/v4/broadcasts/{broadcast_id}/stats"],
74
+ broadcasts_clicks: [:get, "/v4/broadcasts/{broadcast_id}/clicks"],
75
+
76
+ email_templates_list: [:get, "/v4/email_templates"],
77
+ segments_list: [:get, "/v4/segments"],
78
+
79
+ posts_list: [:get, "/v4/posts"],
80
+ posts_get: [:get, "/v4/posts/{id}"],
81
+
82
+ snippets_list: [:get, "/v4/snippets"],
83
+ snippets_get: [:get, "/v4/snippets/{id}"],
84
+ snippets_create: [:post, "/v4/snippets"],
85
+ snippets_update: [:put, "/v4/snippets/{id}"],
86
+
87
+ purchases_list: [:get, "/v4/purchases"],
88
+ purchases_get: [:get, "/v4/purchases/{id}"],
89
+ purchases_create: [:post, "/v4/purchases"],
90
+
91
+ webhooks_list: [:get, "/v4/webhooks"],
92
+ webhooks_create: [:post, "/v4/webhooks"],
93
+ webhooks_delete: [:delete, "/v4/webhooks/{id}"],
94
+
95
+ webhook_endpoints_list: [:get, "/v4/webhook_endpoints"],
96
+ webhook_endpoints_get: [:get, "/v4/webhook_endpoints/{id}"],
97
+ webhook_endpoints_create: [:post, "/v4/webhook_endpoints"],
98
+ webhook_endpoints_update: [:patch, "/v4/webhook_endpoints/{id}"],
99
+ webhook_endpoints_delete: [:delete, "/v4/webhook_endpoints/{id}"],
100
+ webhook_endpoints_rotate_secret: [:post, "/v4/webhook_endpoints/{id}/rotate_secret"],
101
+ webhook_endpoints_revoke_previous_secret: [:post, "/v4/webhook_endpoints/{id}/revoke_previous_secret"],
102
+
103
+ bulk_create_subscribers: [:post, "/v4/bulk/subscribers"],
104
+ bulk_create_custom_fields: [:post, "/v4/bulk/custom_fields"],
105
+ bulk_update_custom_field_values: [:post, "/v4/bulk/custom_fields/subscribers"],
106
+ bulk_add_subscribers_to_forms: [:post, "/v4/bulk/forms/subscribers"],
107
+ bulk_create_tags: [:post, "/v4/bulk/tags"],
108
+ bulk_delete_tags: [:delete, "/v4/bulk/tags"],
109
+ bulk_tag_subscribers: [:post, "/v4/bulk/tags/subscribers"],
110
+ bulk_remove_tag_subscribers: [:delete, "/v4/bulk/tags/subscribers"]
111
+ }.each_value(&:freeze).freeze
112
+
113
+ # The logical response type an operation's envelope holds — the same
114
+ # grouping as the Kit::Objects class the resource builds — for the
115
+ # operations whose envelope key alone does not say: "stats" is three
116
+ # different objects, "broadcast"/"broadcasts" hold BroadcastStats on the
117
+ # stats endpoints, "email(s)" are sequence emails. Every other operation's
118
+ # type is its envelope key singularised (subscribers => subscriber).
119
+ TYPES = {
120
+ account_email_stats: "email_stats",
121
+ account_growth_stats: "growth_stats",
122
+ subscribers_stats: "subscriber_stats",
123
+ broadcasts_stats: "broadcast_stats",
124
+ broadcasts_stats_list: "broadcast_stats",
125
+ sequences_emails: "sequence_email",
126
+ sequences_create_email: "sequence_email",
127
+ sequences_email: "sequence_email",
128
+ sequences_update_email: "sequence_email",
129
+ account_creator_profile: "creator_profile"
130
+ }.freeze
131
+ end
132
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require_relative "../errors"
5
+
6
+ module Kit
7
+ module Testing # rubocop:disable Style/Documentation -- documented in lib/kit/testing.rb
8
+ # WebMock stubs by operation name, so a consumer spec reads as "the upsert
9
+ # succeeds" instead of as a URL, a header hash and a hand-written envelope:
10
+ #
11
+ # Kit::Testing.stub(:subscribers_create, id: 500) # 200 with the documented body
12
+ # Kit::Testing.stub(:tags_tag_subscriber, tag_id: 7, id: 500) # path params by name
13
+ # Kit::Testing.stub(:tags_list, items: [{ name: "vip" }], has_next_page: true)
14
+ # Kit::Testing.stub_error(:account_get, 401, "The API key is invalid")
15
+ # Kit::Testing.stub_rate_limited(:subscribers_create, retry_after: 7)
16
+ #
17
+ # Each returns WebMock's request stub, so `.with(...)`, `.to_return(...)`
18
+ # chaining and `expect(stub).to have_been_requested` work as usual. Path
19
+ # params not given match any value; any query string matches. WebMock is
20
+ # not a dependency of this gem: require it in your spec_helper.
21
+ module Stubs
22
+ BASE_URL = "https://api.kit.com"
23
+ JSON_HEADERS = { "Content-Type" => "application/json" }.freeze
24
+
25
+ # A successful response for `operation`: the documented example at
26
+ # `http_status:` (default: the first Kit documents) with `overrides` on
27
+ # its envelope object. Path params are given by name (`id:`, `tag_id:`);
28
+ # for a list operation `items:` and the pagination keywords shape the
29
+ # page as #list_json does. A path param that is also a field of the
30
+ # response object (`id:` on subscribers_get) applies to both, so the
31
+ # body answers with the id that was asked for.
32
+ def stub(operation, http_status: nil, **params)
33
+ fixture = Fixtures.for(operation)
34
+ template = operation(operation).last
35
+ path_params = params.select { |key, _| template.include?("{#{key}}") }
36
+ overrides = params.reject { |key, _| path_params.key?(key) && !body_field?(fixture, key) }
37
+ status = Fixtures.status_for(fixture, http_status)
38
+ request_stub(operation, path_params).to_return(status: status.to_i, headers: JSON_HEADERS,
39
+ body: stub_body(operation, fixture, status, overrides))
40
+ end
41
+
42
+ # A failing response: Kit's { "errors" => [...] } envelope at `status`.
43
+ def stub_error(operation, status, *messages, **path_params)
44
+ request_stub(operation, path_params)
45
+ .to_return(status: status, headers: JSON_HEADERS, body: JSON.generate(error_json(*messages)))
46
+ end
47
+
48
+ # A 429 with `Retry-After` (seconds), as Kit sends it.
49
+ def stub_rate_limited(operation, retry_after: 30, **path_params)
50
+ request_stub(operation, path_params)
51
+ .to_return(status: 429, headers: JSON_HEADERS.merge("Retry-After" => retry_after.to_s),
52
+ body: JSON.generate(error_json("Rate limit exceeded")))
53
+ end
54
+
55
+ # The URL of an operation with its path params filled in: a String when
56
+ # every placeholder is given (for `a_request(verb, url)`), else a Regexp
57
+ # with `[^/?]+` for the rest. Neither admits a query string; stubs do.
58
+ def url_for(operation, **path_params)
59
+ template, given = url_parts(operation, path_params)
60
+ return "#{BASE_URL}#{fill(template, given)}" if complete?(template, given)
61
+
62
+ /\A#{Regexp.escape(BASE_URL)}#{path_pattern(template, given)}\z/
63
+ end
64
+
65
+ private
66
+
67
+ # The template and the given params as encoded path segments — exactly
68
+ # what Resources::Base#path_id sends, so `id: "a b"` matches `/a%20b` and
69
+ # `id: "1/unsubscribe"` cannot rewrite the route; nil/blank raises like
70
+ # the client does instead of collapsing onto the parent route.
71
+ def url_parts(operation, path_params)
72
+ template = operation(operation).last
73
+ unknown = path_params.keys.map(&:to_s) - placeholders(template)
74
+ raise ArgumentError, "#{unknown.first.inspect} is not a path param of #{template}" if unknown.any?
75
+
76
+ [template, path_params.to_h { |key, value| [key.to_s, path_segment(key, value)] }]
77
+ end
78
+
79
+ def path_segment(key, value)
80
+ raise ArgumentError, "#{key} must not be nil or blank" if value.nil? || value.to_s.strip.empty?
81
+
82
+ ERB::Util.url_encode(value.to_s)
83
+ end
84
+
85
+ def placeholders(template) = template.scan(/\{(\w+)\}/).flatten
86
+ def complete?(template, given) = (placeholders(template) - given.keys).empty?
87
+ def fill(template, given) = template.gsub(/\{(\w+)\}/) { given.fetch(Regexp.last_match(1)) }
88
+
89
+ def path_pattern(template, given)
90
+ Regexp.escape(template).gsub(/\\\{(\w+)\\\}/) do
91
+ given.key?(Regexp.last_match(1)) ? Regexp.escape(given[Regexp.last_match(1)]) : "[^/?]+"
92
+ end
93
+ end
94
+
95
+ # Stubs match the path with any query string, since list filters and
96
+ # remove_*_by_email travel there.
97
+ def request_stub(operation, path_params)
98
+ webmock!
99
+ verb, = operation(operation)
100
+ template, given = url_parts(operation, path_params)
101
+ WebMock::API.stub_request(verb, /\A#{Regexp.escape(BASE_URL)}#{path_pattern(template, given)}(\?.*)?\z/)
102
+ end
103
+
104
+ # True when `key` is a documented field of the operation's response type
105
+ # (so a same-named path param should also override the body).
106
+ def body_field?(fixture, key)
107
+ type = fixture["type"]
108
+ type && Fixtures::KNOWN_FIELDS[type]&.include?(key.to_s)
109
+ end
110
+
111
+ # The body for the selected status: the list page (list operations
112
+ # document one status), the status-specific example (bulk 202s are
113
+ # empty; a 201 may differ from the 200), or nothing for a 204.
114
+ def stub_body(operation, fixture, status, overrides)
115
+ case fixture["kind"]
116
+ when "none" then ""
117
+ when "list" then JSON.generate(list_body(operation, overrides))
118
+ else JSON.generate(response(operation, http_status: status, **overrides))
119
+ end
120
+ end
121
+
122
+ # A list page: `items:` (rows or a count), the pagination keywords, and
123
+ # any remaining keys — a same-named path field such as sequence_id — are
124
+ # applied to every row.
125
+ def list_body(operation, overrides)
126
+ items = overrides.fetch(:items, 1)
127
+ pagination = overrides.slice(*PAGINATION_FIELDS)
128
+ row_fields = overrides.except(:items, *PAGINATION_FIELDS)
129
+ rows = items.is_a?(Integer) ? Array.new(items) { |i| { "id" => attributes(operation)["id"].to_i + i } } : items
130
+ # The path is the source of truth for a shared field: it wins over a row.
131
+ rows = rows.map { |row| row.merge(row_fields) } if row_fields.any?
132
+ list_json(operation, rows, **pagination)
133
+ end
134
+
135
+ def webmock!
136
+ return if defined?(WebMock::API)
137
+
138
+ raise ConfigurationError,
139
+ "Kit::Testing.stub needs WebMock: add `gem \"webmock\"` to your test group and require \"webmock/rspec\""
140
+ end
141
+ end
142
+
143
+ extend Stubs
144
+ end
145
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "testing/operations"
4
+ require_relative "testing/fixtures"
5
+
6
+ module Kit
7
+ # Test support for consumers of this gem: `require "kit/testing"` (it is not
8
+ # loaded by `require "kit-rb"`, and depends on no test framework).
9
+ #
10
+ # Every builder starts from Kit's own documented example response for the
11
+ # operation (lib/kit/testing/fixtures.json, generated from the OpenAPI
12
+ # document and kept in step by a contract test), so a consumer spec reads as
13
+ # "the upsert succeeds" instead of a hand-written envelope that drifts when
14
+ # Kit changes a field:
15
+ #
16
+ # Kit::Testing.subscriber_json(id: 500, email_address: "ada@example.com")
17
+ # # => { "subscriber" => { "id" => 500, "email_address" => "ada@example.com", "state" => "active", ... } }
18
+ #
19
+ # Kit::Testing.response(:tags_create, http_status: 201, name: "vip") # any operation, by name
20
+ # Kit::Testing.list_json(:tags_list, [{ name: "vip" }, { name: "beta" }], has_next_page: true)
21
+ # Kit::Testing.error_json("The API key is invalid") # => { "errors" => [...] }
22
+ #
23
+ # Kit::Testing.subscriber(id: 500) # typed Kit::Objects::Subscriber
24
+ # Kit::Testing.account_info(plan_type: "free") # Kit::Objects::AccountInfo
25
+ # Kit::Testing.oauth_token(created_at: Time.now.to_i) # Kit::OAuth::Token
26
+ #
27
+ # Kit::Testing.stub(:subscribers_create, id: 500) # WebMock stub by operation
28
+ # Kit::Testing.stub_error(:account_get, 401, "The API key is invalid")
29
+ #
30
+ # Operation names are `<resource>_<method>` after the client method
31
+ # (Kit::Testing::OPERATIONS). Overrides must be fields Kit documents for
32
+ # that response type (Kit::Testing::TYPES) — a typo, or a field of another
33
+ # type that happens to share the envelope key, raises ArgumentError rather
34
+ # than silently building a response the real API would never send.
35
+ module Testing
36
+ FIXTURES_PATH = Fixtures::PATH
37
+ FIXTURES = Fixtures::ALL
38
+ KNOWN_FIELDS = Fixtures::KNOWN_FIELDS
39
+
40
+ # The canonical operation behind each `<object>_json` / `<object>` builder.
41
+ OBJECTS = {
42
+ subscriber: :subscribers_get,
43
+ tag: :tags_create,
44
+ custom_field: :custom_fields_create,
45
+ sequence: :sequences_get,
46
+ sequence_email: :sequences_email,
47
+ broadcast: :broadcasts_get,
48
+ broadcast_stats: :broadcasts_stats,
49
+ post: :posts_get,
50
+ snippet: :snippets_get,
51
+ purchase: :purchases_get,
52
+ webhook: :webhooks_create,
53
+ webhook_endpoint: :webhook_endpoints_get,
54
+ creator_profile: :account_creator_profile,
55
+ email_stats: :account_email_stats,
56
+ growth_stats: :account_growth_stats,
57
+ subscriber_stats: :subscribers_stats,
58
+ account: :account_get
59
+ }.freeze
60
+
61
+ PAGINATION_FIELDS = %i[has_next_page has_previous_page start_cursor end_cursor per_page total_count].freeze
62
+
63
+ class << self
64
+ # The documented example body for `operation` at `http_status` (default:
65
+ # the first 2xx Kit documents), with `overrides` applied to its envelope
66
+ # object. For a paginated list the overrides apply to the first item;
67
+ # use #list_json to shape the whole page. (The selector is not called
68
+ # `status:` because `status` is a documented field of posts, purchases,
69
+ # broadcasts and webhook endpoints, which must stay overridable.)
70
+ def response(operation, http_status: nil, **overrides)
71
+ fixture = Fixtures.for(operation)
72
+ body = Fixtures.deep_dup(fixture["responses"].fetch(Fixtures.status_for(fixture, http_status)))
73
+ apply_overrides!(fixture, body, overrides) unless overrides.empty?
74
+ body
75
+ end
76
+
77
+ # The envelope object alone (no wrapper key): what `klass.from` receives.
78
+ def attributes(operation, **overrides)
79
+ fixture = Fixtures.for(operation)
80
+ raise ArgumentError, "#{operation} has no envelope object to build" unless %w[object
81
+ list].include?(fixture["kind"])
82
+
83
+ value = response(operation, **overrides).fetch(fixture["key"])
84
+ fixture["kind"] == "list" ? value.first : value
85
+ end
86
+
87
+ # A cursor-paginated page for a list operation. `items` is an Array of
88
+ # override Hashes (each merged over the documented example item) or an
89
+ # Integer count of example items; pagination fields are keywords.
90
+ def list_json(operation, items = 1, **pagination)
91
+ fixture = Fixtures.for(operation)
92
+ raise ArgumentError, "#{operation} is not a paginated list" unless fixture["kind"] == "list"
93
+
94
+ body = Fixtures.deep_dup(fixture["responses"].values.first)
95
+ rows = list_rows(fixture["type"], attributes(operation), items)
96
+ body[fixture["key"]] = rows
97
+ body["pagination"] = pagination_json(body["pagination"], **page_fields(rows, pagination))
98
+ body
99
+ end
100
+
101
+ # A pagination object: `example` (default: the tags list's) with exactly
102
+ # the given fields replaced — an omitted keyword keeps the example's
103
+ # value, an explicit nil clears a cursor. list_json passes the terminal-
104
+ # page defaults itself.
105
+ def pagination_json(example = Fixtures.for(:tags_list)["responses"]["200"]["pagination"], **fields)
106
+ unknown = fields.keys - PAGINATION_FIELDS
107
+ if unknown.any?
108
+ raise ArgumentError, "unknown pagination field(s) #{unknown.inspect}; known: #{PAGINATION_FIELDS.join(", ")}"
109
+ end
110
+
111
+ page = example.dup
112
+ fields.each { |field, value| page[field.to_s] = value }
113
+ Fixtures.deep_dup(page) # neither the frozen example's strings nor the caller's own values
114
+ end
115
+
116
+ # The error envelope Kit sends on every non-2xx: { "errors" => [...] }.
117
+ def error_json(*messages) = { "errors" => Fixtures.deep_dup(messages.flatten) }
118
+
119
+ # The HTTP status a builder defaults to for `operation` (or validates).
120
+ def http_status(operation, http_status = nil) = Fixtures.status_for(Fixtures.for(operation), http_status).to_i
121
+
122
+ # The [verb, path] Kit::Testing::OPERATIONS declares for `operation`
123
+ # (a frozen tuple; the registry cannot be altered through it).
124
+ def operation(name)
125
+ Fixtures.for(name) # validates the name with the same message
126
+ OPERATIONS.fetch(name.to_sym)
127
+ end
128
+
129
+ private
130
+
131
+ # list_json's page is a single terminal page unless told otherwise, and an
132
+ # empty page has no cursors, as the real API answers; keywords still win.
133
+ def page_fields(rows, pagination)
134
+ defaults = { has_next_page: false, has_previous_page: false }
135
+ defaults.merge!(start_cursor: nil, end_cursor: nil) if rows.empty?
136
+ defaults.merge(pagination)
137
+ end
138
+
139
+ # Rows for a page: n deep copies of the example with distinct ids (kept
140
+ # in the example's own type — subscribers_filter documents string ids),
141
+ # or each given override Hash merged over its own deep copy, so mutating
142
+ # one row's nested Hash never changes a sibling.
143
+ def list_rows(type, example, items)
144
+ unless items.is_a?(Integer)
145
+ return items.map do |row|
146
+ Fixtures.merge_fields(type, Fixtures.deep_dup(example), row)
147
+ end
148
+ end
149
+
150
+ Array.new(items) do |i|
151
+ row = Fixtures.deep_dup(example)
152
+ row["id"] = successor_id(example["id"], i) if example.key?("id")
153
+ row
154
+ end
155
+ end
156
+
157
+ def successor_id(id, offset)
158
+ case id
159
+ when Integer then id + offset
160
+ when String then (id.to_i + offset).to_s
161
+ else id
162
+ end
163
+ end
164
+
165
+ def apply_overrides!(fixture, body, overrides)
166
+ key = fixture["key"]
167
+ type = fixture["type"]
168
+ case fixture["kind"]
169
+ when "object" then body[key] = Fixtures.merge_fields(type, body[key], overrides)
170
+ when "list" then body[key][0] = Fixtures.merge_fields(type, body[key][0], overrides)
171
+ else raise ArgumentError, "#{fixture["verb"].upcase} #{fixture["path"]} has no envelope object to override"
172
+ end
173
+ end
174
+ end
175
+
176
+ # Kit::Testing.subscriber_json(id: 1) etc., one per OBJECTS entry: the
177
+ # canonical operation's response with overrides on its envelope object.
178
+ OBJECTS.each do |object, operation|
179
+ define_singleton_method(:"#{object}_json") { |**overrides| response(operation, **overrides) }
180
+ end
181
+ end
182
+ end
183
+
184
+ require_relative "testing/factories"
185
+ require_relative "testing/stubs"
data/lib/kit/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Kit
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/kit-rb.rb CHANGED
@@ -14,10 +14,12 @@ end
14
14
 
15
15
  require "kit/version"
16
16
  require "kit/errors"
17
+ require "kit/tag_names"
17
18
  require "kit/configuration"
18
19
  require "kit/auth/credential"
19
20
  require "kit/auth/api_key"
20
21
  require "kit/auth/oauth"
22
+ require "kit/instrumentation"
21
23
  require "kit/connection"
22
24
  require "kit/pagination"
23
25
  require "kit/oauth/pkce"