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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +41 -0
- data/LICENSE +190 -0
- data/README.md +256 -0
- data/lib/ankusa/admin.rb +141 -0
- data/lib/ankusa/claim_check.rb +155 -0
- data/lib/ankusa/connection.rb +63 -0
- data/lib/ankusa/error.rb +10 -0
- data/lib/ankusa/routes.rb +187 -0
- data/lib/ankusa/sdk/version.rb +7 -0
- data/lib/ankusa/sdk.rb +11 -0
- data/lib/ankusa/sources.rb +298 -0
- data/lib/ankusa/transport.rb +72 -0
- data/lib/ankusa/webhook.rb +38 -0
- metadata +58 -0
|
@@ -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
|
data/lib/ankusa/error.rb
ADDED
|
@@ -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
|
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"
|