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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Ankusa
6
+ # Every failure the claim-check client can raise.
7
+ #
8
+ # `retryable?` is the whole point of this hierarchy: a caller (a queue
9
+ # consumer, typically) needs exactly one bit -- dead-letter or retry -- and
10
+ # nothing here requires it to know the gateway's status codes to get that
11
+ # right.
12
+ #
13
+ # Non-retryable: the ref or expected sha256 is malformed, the gateway said
14
+ # 404/other 4xx, or the bytes that came back don't match the sha256.
15
+ # Retryable: the gateway said 5xx/503, or the request never completed
16
+ # (network error, timeout).
17
+ class ClaimCheckError < Error
18
+ def retryable? = false
19
+ end
20
+
21
+ # The ref string isn't a `urn:ankusa:claim:v1:<tenant>:<claim_id>`
22
+ # claim-check ref, or the expected sha256 isn't 64-char lowercase hex.
23
+ class InvalidClaimRefError < ClaimCheckError
24
+ end
25
+
26
+ # The gateway returned 404: no such object, expired by retention or never
27
+ # written.
28
+ class ClaimNotFoundError < ClaimCheckError
29
+ end
30
+
31
+ # The gateway rejected the request (400 or any other non-404 4xx).
32
+ class ClaimRejectedError < ClaimCheckError
33
+ attr_reader :status, :body
34
+
35
+ def initialize(message, status:, body:)
36
+ super(message)
37
+ @status = status
38
+ @body = body
39
+ end
40
+ end
41
+
42
+ # The sha256 of the bytes the gateway returned doesn't match the expected
43
+ # sha256 the queue message carries. The gateway itself never checks this --
44
+ # see "Redeem a claim" in docs/claim-check.md -- so this is the reader's own
45
+ # end-to-end check, always run before `redeem` returns.
46
+ class ClaimIntegrityError < ClaimCheckError
47
+ end
48
+
49
+ # The gateway is unreachable, or answered 5xx/503. Safe to retry.
50
+ class ClaimCheckUnavailableError < ClaimCheckError
51
+ def retryable? = true
52
+ end
53
+
54
+ # A parsed claim-check ref, ready to become a GET /v1/claims/... request.
55
+ ParsedClaimRef = Data.define(:tenant_id, :claim_id, :path)
56
+
57
+ # Mirrors `#/components/schemas/Ref` in priv/openapi/claim_check.v1.yaml --
58
+ # keep the two in sync. A ref is one string:
59
+ # urn:ankusa:claim:v1:<tenant>:<claim_id>
60
+ #
61
+ # \A...\z, never ^...$: Ruby's ^ and $ are line anchors, so a ref ending in a
62
+ # newline would otherwise parse.
63
+ CLAIM_REF_PATTERN = /\Aurn:ankusa:claim:v1:([A-Za-z0-9_-]{1,64}):([0-7][0-9A-HJKMNP-TV-Z]{25})\z/
64
+
65
+ SHA256_PATTERN = /\A[0-9a-f]{64}\z/
66
+
67
+ # Parses a claim-check ref (the `claim` field of a queue message) into the
68
+ # tenant id, claim id, and GET /v1/claims/{tenant_id}/{claim_id} path. Raises
69
+ # `InvalidClaimRefError` -- never worth retrying -- if `ref` isn't a
70
+ # well-formed ref.
71
+ def self.parse_claim_ref(ref)
72
+ match = ref.is_a?(String) ? CLAIM_REF_PATTERN.match(ref) : nil
73
+ raise InvalidClaimRefError, "invalid claim-check ref: #{ref}" if match.nil?
74
+
75
+ tenant_id = match[1]
76
+ claim_id = match[2]
77
+ ParsedClaimRef.new(tenant_id: tenant_id, claim_id: claim_id, path: "/v1/claims/#{tenant_id}/#{claim_id}")
78
+ end
79
+
80
+ # Redeem claim-check refs against a deployment's claim-check gateway.
81
+ #
82
+ # The gateway itself does no authentication or authorization (see
83
+ # docs/claim-check.md) -- `headers` is for whatever a deployer's own boundary
84
+ # (service mesh, an API gateway) expects in front of it.
85
+ class ClaimCheckClient
86
+ def initialize(base_url, headers: nil, timeout: 10.0, transport: nil)
87
+ @connection = Connection.new(base_url, headers: headers, timeout: timeout, transport: transport)
88
+ end
89
+
90
+ # Redeem a claim-check ref: fetch its bytes and verify them against
91
+ # `sha256` (the queue message's `sha256` field, 64-char lowercase hex)
92
+ # before returning. The gateway does not check integrity itself -- see
93
+ # "Redeem a claim" in docs/claim-check.md -- so this end-to-end check always
94
+ # runs here.
95
+ #
96
+ # Raises a `ClaimCheckError`; check `retryable?` to sort a failure into
97
+ # dead-letter (false) or retry (true).
98
+ def redeem(ref, sha256)
99
+ parsed = Ankusa.parse_claim_ref(ref)
100
+ unless sha256.is_a?(String) && SHA256_PATTERN.match?(sha256)
101
+ raise InvalidClaimRefError, "invalid claim sha256: #{sha256.inspect}"
102
+ end
103
+
104
+ body = fetch_bytes(parsed)
105
+ if Digest::SHA256.hexdigest(body) != sha256
106
+ raise ClaimIntegrityError, "claim sha256 mismatch for #{parsed.tenant_id}/#{parsed.claim_id}"
107
+ end
108
+
109
+ body
110
+ end
111
+
112
+ # Liveness probe: GET /health.
113
+ def health
114
+ response = @connection.request("GET", "/health")
115
+ status = response.status
116
+ if status != 200
117
+ raise ClaimCheckUnavailableError, "claim-check gateway health check failed (#{status})"
118
+ end
119
+
120
+ Connection.parse_json(response.body)
121
+ rescue Transport::Error => e
122
+ raise ClaimCheckUnavailableError, "claim-check gateway unreachable: #{e.message}"
123
+ rescue JSON::ParserError
124
+ raise ClaimCheckUnavailableError, "claim-check gateway health check returned a non-JSON body (#{status})"
125
+ end
126
+
127
+ private
128
+
129
+ def fetch_bytes(parsed)
130
+ response = @connection.request("GET", parsed.path)
131
+ status = response.status
132
+ if status == 404
133
+ raise ClaimNotFoundError, "claim not found: #{parsed.tenant_id}/#{parsed.claim_id}"
134
+ end
135
+
136
+ if status >= 400 && status < 500
137
+ body = Connection.error_body(response)
138
+ raise ClaimRejectedError.new(
139
+ "claim-check rejected redeem (#{status}): #{body.inspect}",
140
+ status: status,
141
+ body: body
142
+ )
143
+ end
144
+
145
+ if status != 200
146
+ raise ClaimCheckUnavailableError,
147
+ "claim-check gateway error (#{status}): #{Connection.error_body(response).inspect}"
148
+ end
149
+
150
+ response.body
151
+ rescue Transport::Error => e
152
+ raise ClaimCheckUnavailableError, "claim-check gateway unreachable: #{e.message}"
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Ankusa
7
+ # Shared by every client, so the URL, query, body, and JSON rules live in one
8
+ # place.
9
+ #
10
+ # @api private
11
+ class Connection
12
+ attr_reader :base_url, :headers
13
+
14
+ def initialize(base_url, headers: nil, timeout: 10.0, transport: nil)
15
+ @base_url = base_url.to_s.chomp("/")
16
+ @headers = (headers || {}).to_h { |key, value| [key.to_s, value.to_s] }
17
+ @transport = transport || Transport::NetHTTP.new(timeout: timeout)
18
+ end
19
+
20
+ # One request. Raises `Transport::Error` for anything that went wrong on the
21
+ # wire; mapping that to a client error is the caller's job.
22
+ #
23
+ # `query` entries whose value is nil are dropped, so an absent parameter is
24
+ # never sent as `=`. A non-nil `json` becomes the body, with the client
25
+ # headers extended by `content-type: application/json` -- a request-specific
26
+ # header of the same name wins.
27
+ def request(http_method, path, query: nil, json: nil)
28
+ url = URI.parse("#{@base_url}#{path}#{query_string(query)}")
29
+ headers = @headers.dup
30
+ body = nil
31
+ unless json.nil?
32
+ body = JSON.generate(json)
33
+ headers["content-type"] = "application/json"
34
+ end
35
+
36
+ @transport.call(Transport::Request.new(http_method: http_method, url: url, headers: headers, body: body))
37
+ end
38
+
39
+ # Parses a response body as JSON. Raises `JSON::ParserError`, which each
40
+ # caller maps to its own error.
41
+ def self.parse_json(body)
42
+ JSON.parse(body.dup.force_encoding(Encoding::UTF_8))
43
+ end
44
+
45
+ # The decoded error body: JSON when the body is JSON regardless of its
46
+ # content-type, otherwise the raw text with invalid bytes scrubbed. An empty
47
+ # body gives `""`.
48
+ def self.error_body(response)
49
+ parse_json(response.body)
50
+ rescue JSON::ParserError
51
+ response.body.dup.force_encoding(Encoding::UTF_8).scrub
52
+ end
53
+
54
+ private
55
+
56
+ def query_string(query)
57
+ pairs = (query || {}).reject { |_, value| value.nil? }.to_a
58
+ return "" if pairs.empty?
59
+
60
+ "?#{URI.encode_www_form(pairs)}"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ankusa
4
+ # The root of every error this SDK raises; each client's family base (and so
5
+ # every error below it) is a subclass. It deliberately does not respond to
6
+ # `retryable?`: only a family that defines the retryable/not-retryable split
7
+ # carries that bit, so a caller can't read one off this class by mistake.
8
+ class Error < StandardError
9
+ end
10
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ankusa
4
+ # Every failure the routes client can raise.
5
+ #
6
+ # `retryable?` is the whole point of this hierarchy, exactly as in the
7
+ # claim-check client: a caller (an operator script, a controller loop) needs
8
+ # one bit -- leave the table alone and retry, or surface the rejection.
9
+ #
10
+ # Non-retryable: the listener said 404 (no such route) or another 4xx (a
11
+ # rejected write, a duplicate, the cap), or the id was unusable before any
12
+ # request was sent. Retryable: the listener said 5xx/503 store_unavailable, or
13
+ # the request never completed (network error, timeout).
14
+ class RoutesError < Error
15
+ def retryable? = false
16
+ end
17
+
18
+ # The route `id` cannot be used to build a path.
19
+ #
20
+ # Raised before any request is sent. An id that isn't a string, is empty, or
21
+ # is exactly `.` or `..` is refused: URL parsers normalize those away, so
22
+ # `get_route("..")` would quietly hit /admin/ and return the list page as if
23
+ # it were a route. Every other id is percent-encoded as one path segment,
24
+ # never refused.
25
+ class InvalidRouteIdError < RoutesError
26
+ end
27
+
28
+ # The listener is unreachable, or answered 5xx/503. Safe to retry.
29
+ class RoutesUnavailableError < RoutesError
30
+ def retryable? = true
31
+ end
32
+
33
+ # The listener returned 404: no such route.
34
+ class RouteNotFoundError < RoutesError
35
+ end
36
+
37
+ # The listener rejected the request (400 or any other non-404 4xx).
38
+ #
39
+ # `code` is the body's `error` field (`invalid_route`, `duplicate_route`,
40
+ # `too_many_routes`, ...); `field`, `message`, `conflicting_id` and
41
+ # `max_routes` are carried through when the body supplies them.
42
+ class RoutesRejectedError < RoutesError
43
+ attr_reader :status, :code, :field, :conflicting_id, :max_routes
44
+
45
+ def initialize(message, status:, code:, field: nil, conflicting_id: nil, max_routes: nil)
46
+ super(message)
47
+ @status = status
48
+ @code = code
49
+ @field = field
50
+ @conflicting_id = conflicting_id
51
+ @max_routes = max_routes
52
+ end
53
+ end
54
+
55
+ # Manage routes and global IP rules on the route-management listener
56
+ # (`routes.admin.port`, default 4003).
57
+ #
58
+ # Route ids are percent-encoded as a single path segment, so `/`, `?`, `#` and
59
+ # `%` in an id can't reshape the URL; an id that isn't a string, is empty, or
60
+ # is `.`/`..` raises `InvalidRouteIdError` before any request is sent.
61
+ class RoutesClient
62
+ def initialize(base_url, headers: nil, timeout: 10.0, transport: nil)
63
+ @connection = Connection.new(base_url, headers: headers, timeout: timeout, transport: transport)
64
+ end
65
+
66
+ # Liveness probe: GET /health -> {status, routes}.
67
+ def health
68
+ json(request("GET", "/health"))
69
+ end
70
+
71
+ # GET /admin/routes -> a page of route definitions.
72
+ #
73
+ # `params` may carry `enabled`, `limit` and `cursor`; absent keys are
74
+ # omitted from the query string rather than sent as `=`.
75
+ def list_routes(params = nil)
76
+ json(request("GET", "/admin/routes", query: params))
77
+ end
78
+
79
+ # POST /admin/routes -> the stored route, timestamps included.
80
+ def create_route(input)
81
+ json(request("POST", "/admin/routes", json: input))
82
+ end
83
+
84
+ # GET /admin/routes/{id} -> the route.
85
+ def get_route(id)
86
+ json(request("GET", "/admin/routes/#{route_path(id)}"))
87
+ end
88
+
89
+ # PUT /admin/routes/{id} -> the replaced (or created) route.
90
+ def replace_route(id, input)
91
+ json(request("PUT", "/admin/routes/#{route_path(id)}", json: input))
92
+ end
93
+
94
+ # PATCH /admin/routes/{id} -> the patched route.
95
+ def update_route(id, patch)
96
+ json(request("PATCH", "/admin/routes/#{route_path(id)}", json: patch))
97
+ end
98
+
99
+ # DELETE /admin/routes/{id} (204, no body).
100
+ def delete_route(id)
101
+ request("DELETE", "/admin/routes/#{route_path(id)}")
102
+ nil
103
+ end
104
+
105
+ # GET /admin/ip-rules -> the global IP rules.
106
+ def get_ip_rules
107
+ json(request("GET", "/admin/ip-rules"))
108
+ end
109
+
110
+ # PUT /admin/ip-rules -> the stored rules, as parsed.
111
+ def put_ip_rules(rules)
112
+ json(request("PUT", "/admin/ip-rules", json: rules))
113
+ end
114
+
115
+ # POST /admin/routes/test -> the dry-run decision for `request`.
116
+ def test_route(request)
117
+ json(self.request("POST", "/admin/routes/test", json: request))
118
+ end
119
+
120
+ private
121
+
122
+ def request(http_method, path, query: nil, json: nil)
123
+ response = @connection.request(http_method, path, query: query, json: json)
124
+ raise_for_status(response)
125
+ response
126
+ rescue Transport::Error => e
127
+ raise RoutesUnavailableError, "routes listener unreachable: #{e.message}"
128
+ end
129
+
130
+ def json(response)
131
+ Connection.parse_json(response.body)
132
+ rescue JSON::ParserError
133
+ raise RoutesUnavailableError, "routes listener returned a non-JSON body (#{response.status})"
134
+ end
135
+
136
+ def raise_for_status(response)
137
+ status = response.status
138
+ return if status >= 200 && status < 300
139
+
140
+ if status == 404
141
+ raise RouteNotFoundError, "route not found (#{status})"
142
+ end
143
+
144
+ if status >= 400 && status < 500
145
+ body = Connection.error_body(response)
146
+ raise RoutesRejectedError.new(
147
+ rejection_message(status, body),
148
+ status: status,
149
+ code: field(body, "error"),
150
+ field: field(body, "field"),
151
+ conflicting_id: field(body, "conflicting_id"),
152
+ max_routes: field(body, "max_routes")
153
+ )
154
+ end
155
+
156
+ raise RoutesUnavailableError, "routes listener error (#{status}): #{Connection.error_body(response).inspect}"
157
+ end
158
+
159
+ # The server's own `message` when the body carries a string one, else the
160
+ # rejection rendered whole.
161
+ def rejection_message(status, body)
162
+ message = field(body, "message")
163
+ return message if message.is_a?(String)
164
+
165
+ "routes listener rejected the request (#{status}): #{body.inspect}"
166
+ end
167
+
168
+ def field(body, name)
169
+ body.is_a?(Hash) ? body[name] : nil
170
+ end
171
+
172
+ # Validates `id` and percent-encodes it as ONE path segment.
173
+ #
174
+ # `.` and `..` (and an empty id) are refused rather than encoded: a URL
175
+ # parser normalizes them away before the request is sent, so `..` would
176
+ # become /admin/ and an empty id or `.` the collection endpoint -- the
177
+ # caller would get the list page back as if it were a route. Everything else
178
+ # travels with `/`, `?`, `#`, `%` and space escaped as %2F %3F %23 %25 %20
179
+ # instead of reshaping the URL.
180
+ def route_path(id)
181
+ raise InvalidRouteIdError, "route id must be a string, got #{id.inspect}" unless id.is_a?(String)
182
+ raise InvalidRouteIdError, "invalid route id #{id.inspect}" if ["", ".", ".."].include?(id)
183
+
184
+ id.b.gsub(/[^A-Za-z0-9_.~-]/) { |char| format("%%%02X", char.ord) }
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ankusa
4
+ module SDK
5
+ VERSION = "0.3.0"
6
+ end
7
+ end
data/lib/ankusa/sdk.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sdk/version"
4
+ require_relative "error"
5
+ require_relative "transport"
6
+ require_relative "connection"
7
+ require_relative "webhook"
8
+ require_relative "claim_check"
9
+ require_relative "routes"
10
+ require_relative "admin"
11
+ require_relative "sources"