ankusa-sdk 0.3.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,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ankusa
4
+ # Every failure the source-management client can raise.
5
+ #
6
+ # Every subclass carries the HTTP `status` and decoded `body` of the response
7
+ # that produced it (both nil when no response was involved, e.g. a transport
8
+ # failure). Unlike the other families this one has no `retryable?`, matching
9
+ # the reference SDK.
10
+ class SourcesError < Error
11
+ attr_reader :status, :body
12
+
13
+ def initialize(message = nil, status: nil, body: nil)
14
+ super(message)
15
+ @status = status
16
+ @body = body
17
+ end
18
+ end
19
+
20
+ # 404: no such source for this tenant.
21
+ class SourceNotFoundError < SourcesError
22
+ end
23
+
24
+ # 409 source_exists: a source with that name already exists.
25
+ class SourceConflictError < SourcesError
26
+ end
27
+
28
+ # 409 source_store_read_only: the deployment's source store is a static seed,
29
+ # so writes are impossible.
30
+ class SourceStoreReadOnlyError < SourcesError
31
+ end
32
+
33
+ # 400: bad tenant, bad source name, or a spec the server rejected.
34
+ #
35
+ # `message` is the server's own `message` (for a bad spec) or the `error` code
36
+ # (for a bad tenant/name, which has no message).
37
+ class SourceInvalidError < SourcesError
38
+ end
39
+
40
+ # The admin API is unreachable, timed out, or answered 5xx. Safe to retry.
41
+ class SourcesUnavailableError < SourcesError
42
+ end
43
+
44
+ # GET /health reported a version other than `expected_version`.
45
+ class VersionMismatchError < SourcesError
46
+ end
47
+
48
+ # The writable fields of a source, as submitted to POST/PUT.
49
+ #
50
+ # `verify` is optional (absent means `{"type" => "none"}` on the server);
51
+ # `sinks` is required and must be non-empty -- the server validates all of
52
+ # this exactly as the YAML config does.
53
+ SourceSpec = Data.define(:sinks, :verify, :on_verify_failure) do
54
+ def initialize(sinks:, verify: nil, on_verify_failure: nil) = super
55
+
56
+ # The JSON body for a create/update, omitting unset (nil) fields.
57
+ def to_request_body
58
+ body = {"sinks" => sinks}
59
+ body["verify"] = verify unless verify.nil?
60
+ body["on_verify_failure"] = on_verify_failure unless on_verify_failure.nil?
61
+ body
62
+ end
63
+ end
64
+
65
+ # A stored source as the admin API reports it: redacted spec plus the derived
66
+ # identity fields.
67
+ Source = Data.define(:tenant, :name, :source_id, :ingest_path, :verify, :on_verify_failure, :sinks) do
68
+ def self.from_h(data)
69
+ new(
70
+ tenant: data.fetch("tenant"),
71
+ name: data.fetch("name"),
72
+ source_id: data.fetch("source_id"),
73
+ ingest_path: data.fetch("ingest_path"),
74
+ verify: data["verify"] || {"type" => "none"},
75
+ on_verify_failure: data["on_verify_failure"],
76
+ sinks: data["sinks"] || []
77
+ )
78
+ end
79
+ end
80
+
81
+ # Manage a deployment's tenant-scoped sources via `Ankusa.Admin.Router` (the
82
+ # same `admin.port` as the operator API).
83
+ #
84
+ # The router does no authentication (by design, like the rest of this port),
85
+ # and every source it returns has its secrets redacted. So a `Source` read
86
+ # back here is never useful for editing: resending its `verify` map is not the
87
+ # same as resending the stored secret. Callers that hold the secret supply it
88
+ # through `SourceSpec`.
89
+ #
90
+ # Only `[A-Za-z0-9_-]{1,64}` tenants and names are accepted, and both are
91
+ # checked before any path is built, so a caller-supplied name cannot escape
92
+ # its tenant through URL normalization.
93
+ #
94
+ # `expected_version` is an optional safety latch: when set, the first API call
95
+ # fetches GET /health once, compares its `"version"` field against the
96
+ # expected value, caches the fetched version, and raises
97
+ # `VersionMismatchError` on any mismatch. Every subsequent call re-checks the
98
+ # cached value without another request.
99
+ class SourcesClient
100
+ # The same rule `Ankusa::CLAIM_REF_PATTERN` uses for its tenant. Anything
101
+ # outside it is rejected before a path is built: URL parsers normalize dot
102
+ # segments, so an unvalidated "../" would escape the tenant scope before the
103
+ # server sees it.
104
+ SAFE_ID = /\A[A-Za-z0-9_-]{1,64}\z/
105
+
106
+ def initialize(base_url, expected_version: nil, timeout: 10.0, transport: nil)
107
+ @connection = Connection.new(base_url, timeout: timeout, transport: transport)
108
+ @expected_version = expected_version
109
+ @server_version = nil
110
+ end
111
+
112
+ # The deployment's Ankusa version: GET /health ["version"].
113
+ #
114
+ # Fetched once and cached; when `expected_version` was set this also
115
+ # enforces it, so a mismatched deployment raises `VersionMismatchError` here
116
+ # too.
117
+ def server_version
118
+ @server_version = fetch_version if @server_version.nil?
119
+ check_version
120
+ @server_version
121
+ end
122
+
123
+ # List a tenant's sources: GET /v1/tenants/<tenant>/sources.
124
+ def list_sources(tenant)
125
+ validate_tenant(tenant)
126
+ ensure_version
127
+ response = request("GET", "/v1/tenants/#{tenant}/sources")
128
+ raise_for_status(response)
129
+ parse_json(response).fetch("entries").map { |entry| Source.from_h(entry) }
130
+ end
131
+
132
+ # Fetch one source: GET /v1/tenants/<tenant>/sources/<name>.
133
+ def get_source(tenant, name)
134
+ validate_tenant(tenant)
135
+ validate_name(name)
136
+ ensure_version
137
+ response = request("GET", "/v1/tenants/#{tenant}/sources/#{name}")
138
+ raise_for_status(response)
139
+ Source.from_h(parse_json(response))
140
+ end
141
+
142
+ # Create a source: POST /v1/tenants/<tenant>/sources.
143
+ #
144
+ # The source name travels in the body (plus `name`); the tenant comes from
145
+ # the URL and wins over any `"tenant"` key inside `spec`.
146
+ def create_source(tenant, name, spec)
147
+ validate_tenant(tenant)
148
+ validate_name(name)
149
+ ensure_version
150
+ body = spec.to_request_body.merge("name" => name)
151
+ response = request("POST", "/v1/tenants/#{tenant}/sources", json: body)
152
+ raise_for_status(response)
153
+ Source.from_h(parse_json(response))
154
+ end
155
+
156
+ # Replace a source: PUT /v1/tenants/<tenant>/sources/<name>.
157
+ #
158
+ # The name comes from the URL; a `"name"` key inside `spec` is never sent
159
+ # (`SourceSpec` has no such field).
160
+ def update_source(tenant, name, spec)
161
+ validate_tenant(tenant)
162
+ validate_name(name)
163
+ ensure_version
164
+ response = request("PUT", "/v1/tenants/#{tenant}/sources/#{name}", json: spec.to_request_body)
165
+ raise_for_status(response)
166
+ Source.from_h(parse_json(response))
167
+ end
168
+
169
+ # Delete a source: DELETE /v1/tenants/<tenant>/sources/<name>.
170
+ #
171
+ # Succeeds with no return value (the server answers 204 with an empty body);
172
+ # a missing source raises `SourceNotFoundError`.
173
+ def delete_source(tenant, name)
174
+ validate_tenant(tenant)
175
+ validate_name(name)
176
+ ensure_version
177
+ response = request("DELETE", "/v1/tenants/#{tenant}/sources/#{name}")
178
+ raise_for_status(response)
179
+ nil
180
+ end
181
+
182
+ private
183
+
184
+ # The optional version latch: only when `expected_version` is set does the
185
+ # first API call fetch /health once, cache the version, and enforce it.
186
+ def ensure_version
187
+ return if @expected_version.nil?
188
+
189
+ @server_version = fetch_version if @server_version.nil?
190
+ check_version
191
+ end
192
+
193
+ def check_version
194
+ return if @expected_version.nil? || @server_version == @expected_version
195
+
196
+ raise VersionMismatchError,
197
+ "expected Ankusa version #{@expected_version.inspect}, server reports #{@server_version.inspect}"
198
+ end
199
+
200
+ def fetch_version
201
+ response = request("GET", "/health")
202
+ status = response.status
203
+ if status != 200
204
+ body = Connection.error_body(response)
205
+ raise SourcesUnavailableError.new(
206
+ "ankusa admin API health check failed (#{status}): #{body.inspect}",
207
+ status: status,
208
+ body: body
209
+ )
210
+ end
211
+
212
+ begin
213
+ data = Connection.parse_json(response.body)
214
+ rescue JSON::ParserError
215
+ raise SourcesUnavailableError, "ankusa admin API health check returned a non-JSON body (#{status})"
216
+ end
217
+
218
+ version = data.is_a?(Hash) ? data["version"] : nil
219
+ unless version.is_a?(String)
220
+ raise SourcesUnavailableError,
221
+ "ankusa admin API health check returned no version (#{status}): #{data.inspect}"
222
+ end
223
+
224
+ version
225
+ end
226
+
227
+ def request(http_method, path, json: nil)
228
+ @connection.request(http_method, path, json: json)
229
+ rescue Transport::Error => e
230
+ raise SourcesUnavailableError, "ankusa admin API unreachable: #{e.message}"
231
+ end
232
+
233
+ def parse_json(response)
234
+ Connection.parse_json(response.body)
235
+ rescue JSON::ParserError
236
+ # Deliberate deviation from the reference SDK, which lets its decoder's
237
+ # ValueError escape here: every other failure this client can produce is a
238
+ # SourcesError, so a 2xx that isn't JSON is one too.
239
+ raise SourcesUnavailableError.new(
240
+ "ankusa admin API returned a non-JSON body (#{response.status})",
241
+ status: response.status
242
+ )
243
+ end
244
+
245
+ def raise_for_status(response)
246
+ status = response.status
247
+ return if status >= 200 && status < 300
248
+
249
+ body = Connection.error_body(response)
250
+ if status == 404
251
+ raise SourceNotFoundError.new("source not found (#{status}): #{body.inspect}", status: status, body: body)
252
+ end
253
+
254
+ if status == 400
255
+ raise SourceInvalidError.new(invalid_message(body, status), status: status, body: body)
256
+ end
257
+
258
+ if status == 409
259
+ if body.is_a?(Hash) && body["error"] == "source_store_read_only"
260
+ raise SourceStoreReadOnlyError.new(
261
+ "source store is read-only (#{status}): #{body.inspect}",
262
+ status: status,
263
+ body: body
264
+ )
265
+ end
266
+
267
+ raise SourceConflictError.new("source already exists (#{status}): #{body.inspect}", status: status, body: body)
268
+ end
269
+
270
+ raise SourcesUnavailableError.new("ankusa admin API error (#{status}): #{body.inspect}", status: status, body: body)
271
+ end
272
+
273
+ def invalid_message(body, status)
274
+ if body.is_a?(Hash)
275
+ message = body["message"] || body["error"]
276
+ return message if message.is_a?(String)
277
+ end
278
+
279
+ "invalid source (#{status}): #{body.inspect}"
280
+ end
281
+
282
+ # Reject a tenant that is not `[A-Za-z0-9_-]{1,64}` before any path is
283
+ # built.
284
+ def validate_tenant(tenant)
285
+ return if tenant.is_a?(String) && SAFE_ID.match?(tenant)
286
+
287
+ raise SourceInvalidError, "invalid tenant: #{tenant.inspect}"
288
+ end
289
+
290
+ # Reject a source name that is not `[A-Za-z0-9_-]{1,64}` before any path is
291
+ # built.
292
+ def validate_name(name)
293
+ return if name.is_a?(String) && SAFE_ID.match?(name)
294
+
295
+ raise SourceInvalidError, "invalid source name: #{name.inspect}"
296
+ end
297
+ end
298
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "uri"
6
+ require "zlib"
7
+
8
+ module Ankusa
9
+ # The injectable HTTP hook every client sends through.
10
+ #
11
+ # A transport is any object that responds to `call(Request) -> Response`,
12
+ # including a lambda. `Transport::NetHTTP` is the default, used when a client
13
+ # is built without one; tests inject a lambda and never touch the network.
14
+ module Transport
15
+ # One HTTP request. `http_method` is an upcased String -- not `method`,
16
+ # which would shadow `Object#method` -- and `url` is a full `URI::HTTP`.
17
+ Request = Data.define(:http_method, :url, :headers, :body)
18
+
19
+ # One HTTP response. `headers` has lowercase String keys; `body` is a binary
20
+ # String, `""` when the response carried no body.
21
+ Response = Data.define(:status, :headers, :body)
22
+
23
+ # Raised for any transport failure: connection refused, DNS failure, a
24
+ # timeout, a TLS error, a truncated response. An injected transport raises
25
+ # it too, to signal unreachable without a network.
26
+ class Error < Ankusa::Error
27
+ end
28
+
29
+ # The default transport: one `Net::HTTP` request per call, no redirects, no
30
+ # retries, and one timeout for connecting, reading, and writing.
31
+ class NetHTTP
32
+ def initialize(timeout:)
33
+ @timeout = timeout
34
+ end
35
+
36
+ def call(request)
37
+ url = request.url
38
+ # hostname, not host: for an IPv6 literal host is "[::1]" with the
39
+ # brackets still on, and Net::HTTP.addr_port adds them back from the
40
+ # bare address, so "[::1]" would go to DNS as written.
41
+ http = Net::HTTP.new(url.hostname, url.port)
42
+ http.use_ssl = url.scheme == "https"
43
+ http.open_timeout = @timeout
44
+ http.read_timeout = @timeout
45
+ http.write_timeout = @timeout
46
+ # Net::HTTP retries idempotent requests (GET, PUT, DELETE, ...) once by
47
+ # default, which would send -- and so in the conformance vectors,
48
+ # record -- a second request.
49
+ http.max_retries = 0
50
+
51
+ http_request = Net::HTTPGenericRequest.new(
52
+ request.http_method,
53
+ !request.body.nil?,
54
+ request.http_method != "HEAD",
55
+ url.request_uri,
56
+ request.headers
57
+ )
58
+ http_request.body = request.body unless request.body.nil?
59
+
60
+ response = http.request(http_request)
61
+ Response.new(
62
+ status: response.code.to_i,
63
+ headers: response.each_header.to_h,
64
+ body: (response.body || "").b
65
+ )
66
+ rescue SystemCallError, IOError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError,
67
+ Net::ProtocolError, Net::HTTPBadResponse, Zlib::Error => e
68
+ raise Error, "#{e.class}: #{e.message}"
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ankusa
4
+ # The identity of one HTTP-sink delivery: the headers Ankusa's HTTP sink
5
+ # attaches to every delivery.
6
+ #
7
+ # `content_type` and `tenant` are nil when the delivery had no such header
8
+ # (a tenant only travels when the source has one).
9
+ HookHeaders = Data.define(:id, :source, :tenant, :content_type)
10
+
11
+ # Raised by `Ankusa.parse_headers` when `x-ankusa-id` is absent or empty.
12
+ #
13
+ # Every other Ankusa header is optional; this one is the identity a receiver
14
+ # dedupes on, so a delivery without it is a framework bug, not a malformed but
15
+ # tolerable request.
16
+ class MissingHookIdError < Error
17
+ end
18
+
19
+ # Parses the `x-ankusa-*` headers of one delivery, whatever the mapping's own
20
+ # case behavior: lookup here is always case-insensitive.
21
+ #
22
+ # See "HTTP handoff" in docs/integrations.md for the delivery contract this
23
+ # mirrors. A receiver must dedupe on `x-ankusa-id`: delivery is at-least-once,
24
+ # so the same hook can arrive twice after a retry.
25
+ def self.parse_headers(headers)
26
+ lowered = headers.to_h { |key, value| [key.to_s.downcase, value] }
27
+
28
+ hook_id = lowered["x-ankusa-id"]
29
+ raise MissingHookIdError, "missing x-ankusa-id header" if hook_id.nil? || hook_id.empty?
30
+
31
+ HookHeaders.new(
32
+ id: hook_id,
33
+ source: lowered.fetch("x-ankusa-source", ""),
34
+ tenant: lowered["x-ankusa-tenant"],
35
+ content_type: lowered["content-type"]
36
+ )
37
+ end
38
+ end
metadata ADDED
@@ -0,0 +1,58 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ankusa-sdk
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - James Carr
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Client SDK for Ankusa deployments. Bundles the claim-check gateway client,
13
+ the route-management and operator (admin) clients, the source-management client,
14
+ and a webhook-receiving header helper; more clients (ingest) land here as they're
15
+ built.
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE
22
+ - README.md
23
+ - lib/ankusa/admin.rb
24
+ - lib/ankusa/claim_check.rb
25
+ - lib/ankusa/connection.rb
26
+ - lib/ankusa/error.rb
27
+ - lib/ankusa/routes.rb
28
+ - lib/ankusa/sdk.rb
29
+ - lib/ankusa/sdk/version.rb
30
+ - lib/ankusa/sources.rb
31
+ - lib/ankusa/transport.rb
32
+ - lib/ankusa/webhook.rb
33
+ homepage: https://github.com/jamescarr/ankusa/tree/main/packages/sdk-ruby
34
+ licenses:
35
+ - Apache-2.0
36
+ metadata:
37
+ homepage_uri: https://github.com/jamescarr/ankusa/tree/main/packages/sdk-ruby
38
+ source_code_uri: https://github.com/jamescarr/ankusa
39
+ bug_tracker_uri: https://github.com/jamescarr/ankusa/issues
40
+ changelog_uri: https://github.com/jamescarr/ankusa/blob/main/packages/sdk-ruby/CHANGELOG.md
41
+ rdoc_options: []
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.3'
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ requirements: []
55
+ rubygems_version: 4.0.20
56
+ specification_version: 4
57
+ summary: Client SDK for Ankusa deployments.
58
+ test_files: []