k9o 1.2.0-aarch64-linux → 1.5.0-aarch64-linux
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 +115 -1
- data/README.md +1575 -180
- data/k9o.gemspec +2 -2
- data/lib/k9o/_ffi/kibo_kms_rest_ffi.rb +928 -25
- data/lib/k9o/_ffi/libkibo_kms_rest_ffi.so +0 -0
- data/lib/k9o/ai.rb +15 -0
- data/lib/k9o/analytics.rb +403 -0
- data/lib/k9o/approvals.rb +253 -0
- data/lib/k9o/audit.rb +162 -14
- data/lib/k9o/client.rb +28 -9
- data/lib/k9o/compute.rb +32 -15
- data/lib/k9o/crypto.rb +471 -0
- data/lib/k9o/custody.rb +28 -0
- data/lib/k9o/data.rb +125 -5
- data/lib/k9o/ed25519.rb +182 -0
- data/lib/k9o/end_users.rb +93 -0
- data/lib/k9o/envelope.rb +20 -5
- data/lib/k9o/functions.rb +127 -9
- data/lib/k9o/gate.rb +52 -22
- data/lib/k9o/http_transport.rb +78 -16
- data/lib/k9o/jcs.rb +117 -0
- data/lib/k9o/kerror.rb +57 -5
- data/lib/k9o/keys.rb +131 -0
- data/lib/k9o/kibo.rb +3 -3
- data/lib/k9o/kibo_client_core.rb +385 -0
- data/lib/k9o/kibo_ffi_core.rb +178 -0
- data/lib/k9o/locate.rb +118 -0
- data/lib/k9o/me.rb +27 -87
- data/lib/k9o/metadata.rb +342 -0
- data/lib/k9o/moat.rb +443 -0
- data/lib/k9o/org.rb +81 -68
- data/lib/k9o/orgs.rb +1 -0
- data/lib/k9o/policies.rb +25 -6
- data/lib/k9o/projects.rb +43 -8
- data/lib/k9o/query.rb +174 -0
- data/lib/k9o/rampart.rb +273 -0
- data/lib/k9o/realtime.rb +261 -32
- data/lib/k9o/region.rb +20 -18
- data/lib/k9o/schedules.rb +109 -0
- data/lib/k9o/schema.rb +35 -0
- data/lib/k9o/streams.rb +94 -0
- data/lib/k9o/studio.rb +21 -11
- data/lib/k9o/url_grammar.rb +46 -20
- data/lib/k9o/vault.rb +79 -84
- data/lib/k9o/vectors.rb +124 -0
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +17 -0
- data/sig/k9o/ai.rbs +4 -0
- data/sig/k9o/approvals.rbs +71 -0
- data/sig/k9o/datasets.rbs +125 -0
- data/sig/k9o/gate.rbs +9 -6
- data/sig/k9o/kerror.rbs +24 -0
- data/sig/k9o/kibo_client_core.rbs +25 -0
- data/sig/k9o/metadata.rbs +54 -0
- data/sig/k9o/moat.rbs +29 -0
- data/sig/k9o/query.rbs +26 -0
- data/sig/k9o/rampart.rbs +59 -0
- data/sig/k9o/realtime.rbs +34 -0
- data/sig/k9o/region.rbs +14 -3
- data/sig/k9o/workload.rbs +64 -0
- metadata +33 -4
data/lib/k9o/functions.rb
CHANGED
|
@@ -2,22 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
module K9o
|
|
4
4
|
# Function lifecycle namespace — k9o.functions.*.
|
|
5
|
+
#
|
|
6
|
+
# Upload the function's bytes first (+upload_artifact+ returns the +artifactKey+), then +deploy+ references that
|
|
7
|
+
# artifact. Deploy requires PROJECT_ADMIN and returns with status +deploying+; the function is ready when status
|
|
8
|
+
# flips to +active+.
|
|
5
9
|
class Functions
|
|
10
|
+
ARTIFACT_CONTENT_TYPES = { 'wasm' => 'application/wasm', 'tar' => 'application/x-tar' }.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :triggers
|
|
13
|
+
|
|
6
14
|
def initialize(config)
|
|
7
15
|
@config = config
|
|
16
|
+
@triggers = FunctionTriggers.new(config)
|
|
8
17
|
end
|
|
9
18
|
|
|
10
|
-
|
|
19
|
+
# POST /@<slug>/functions/artifacts with the raw bytes: a WebAssembly module for the gateway tier (for the platform operator; a project's deploy to it is refused) (up to 16 MiB,
|
|
20
|
+
# +kind+ 'wasm') or an OCI image archive for the sandbox tier (up to 256 MiB, +kind+ 'tar'). Uploading the same
|
|
21
|
+
# bytes again returns the same key. The envelope's data is { 'artifactKey' => String }.
|
|
22
|
+
def upload_artifact(bytes, kind)
|
|
23
|
+
content_type = ARTIFACT_CONTENT_TYPES[kind.to_s]
|
|
24
|
+
raise ArgumentError, "kind must be 'wasm' or 'tar'" if content_type.nil?
|
|
25
|
+
|
|
26
|
+
bytes = bytes.read if bytes.respond_to?(:read)
|
|
27
|
+
raise ArgumentError, 'bytes must be a String or an IO' unless bytes.is_a?(String)
|
|
28
|
+
|
|
11
29
|
slug = require_project_slug
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
30
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
31
|
+
path: UrlGrammar.project_path(slug, 'functions/artifacts'),
|
|
32
|
+
raw_body: bytes, headers: { 'content-type' => content_type })
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# POST /@<slug>/functions — deploy or redeploy from an uploaded artifact.
|
|
36
|
+
# Functions run in the sandbox. tier: 'sandbox' (OCI image archive); runtime: bun python go java kotlin dotnet
|
|
37
|
+
# swift php ruby rust zig. 'gateway' (WebAssembly) is for the platform operator and is refused for a customer
|
|
38
|
+
# deploy; tinygo and assemblyscript run only there, so they cannot be deployed to a project.
|
|
39
|
+
# artifact_key: the key +upload_artifact+ returned; version: label for this deploy (1 to 64 characters)
|
|
40
|
+
def deploy(name:, runtime:, tier:, artifact_key:, version:, description: nil)
|
|
41
|
+
slug = require_project_slug
|
|
42
|
+
body = { 'name' => name, 'runtime' => runtime, 'tier' => tier, 'artifactKey' => artifact_key,
|
|
43
|
+
'version' => version }
|
|
44
|
+
body['description'] = description unless description.nil?
|
|
15
45
|
HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'functions'), body: body)
|
|
16
46
|
end
|
|
17
47
|
|
|
18
|
-
|
|
48
|
+
# GET /@<slug>/functions (cursor pagination).
|
|
49
|
+
def list(status: nil, limit: nil, cursor: nil)
|
|
19
50
|
slug = require_project_slug
|
|
20
|
-
q =
|
|
51
|
+
q = {}
|
|
52
|
+
q['status'] = status unless status.nil?
|
|
53
|
+
q['limit'] = limit unless limit.nil?
|
|
54
|
+
q['cursor'] = cursor unless cursor.nil?
|
|
21
55
|
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'functions'), query: q)
|
|
22
56
|
end
|
|
23
57
|
|
|
@@ -26,16 +60,25 @@ module K9o
|
|
|
26
60
|
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, "functions/#{enc(name)}"))
|
|
27
61
|
end
|
|
28
62
|
|
|
63
|
+
# Retire a function; its name enters a 24h cooldown.
|
|
29
64
|
def delete(name)
|
|
30
65
|
slug = require_project_slug
|
|
31
66
|
HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.project_path(slug, "functions/#{enc(name)}"))
|
|
32
67
|
end
|
|
33
68
|
|
|
34
|
-
#
|
|
35
|
-
|
|
69
|
+
# POST /@<slug>/functions/<name>/invoke. +payload+ is sent as the +payload+ field of the request body and passed
|
|
70
|
+
# to the function runtime verbatim. Optional controls: +background+ (run asynchronously), +delegation+
|
|
71
|
+
# ({ 'token' => ..., 'invokeSessionId' => ... }), +on_behalf_of+, +confined_to+.
|
|
72
|
+
def invoke(name, payload = nil, background: nil, delegation: nil, on_behalf_of: nil, confined_to: nil)
|
|
36
73
|
slug = require_project_slug
|
|
74
|
+
body = {}
|
|
75
|
+
body['payload'] = payload unless payload.nil?
|
|
76
|
+
body['background'] = background unless background.nil?
|
|
77
|
+
body['delegation'] = delegation unless delegation.nil?
|
|
78
|
+
body['onBehalfOf'] = on_behalf_of unless on_behalf_of.nil?
|
|
79
|
+
body['confinedTo'] = confined_to unless confined_to.nil?
|
|
37
80
|
HttpTransport.request(config: @config, method: 'POST',
|
|
38
|
-
path: UrlGrammar.project_path(slug, "functions/#{enc(name)}/invoke"), body:
|
|
81
|
+
path: UrlGrammar.project_path(slug, "functions/#{enc(name)}/invoke"), body: body)
|
|
39
82
|
end
|
|
40
83
|
|
|
41
84
|
private
|
|
@@ -48,4 +91,79 @@ module K9o
|
|
|
48
91
|
::URI.encode_www_form_component(s)
|
|
49
92
|
end
|
|
50
93
|
end
|
|
94
|
+
|
|
95
|
+
# Event triggers: k9o.functions.triggers.* run a function when a record is inserted, updated, deleted or upserted
|
|
96
|
+
# in a collection. They need the project admin role. For triggers on a timetable see +k9o.schedules+.
|
|
97
|
+
#
|
|
98
|
+
# list(name) GET /@<slug>/functions/<name>/triggers
|
|
99
|
+
# create(name, collection:, events:, ...) POST /@<slug>/functions/<name>/triggers
|
|
100
|
+
# update(name, trigger_id, **fields) PATCH /@<slug>/functions/<name>/triggers/<id>
|
|
101
|
+
# delete(name, trigger_id) DELETE /@<slug>/functions/<name>/triggers/<id> (204, no body)
|
|
102
|
+
class FunctionTriggers
|
|
103
|
+
EVENTS = %w[insert update delete upsert].freeze
|
|
104
|
+
COLLECTION_RE = /\A[a-zA-Z_][a-zA-Z0-9_]{0,127}\z/.freeze
|
|
105
|
+
UPDATE_FIELDS = %i[events filter enabled log_ok_invocations].freeze
|
|
106
|
+
|
|
107
|
+
def initialize(config)
|
|
108
|
+
@config = config
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Every trigger of the function, newest first. Not paginated.
|
|
112
|
+
def list(name)
|
|
113
|
+
call('GET', path(name))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Create an event trigger; answers 201 with the trigger. +events+ lists 1 to 4 of insert, update, delete, upsert.
|
|
117
|
+
# +filter+ is a predicate Hash over the record; leave it out to fire on every matching event.
|
|
118
|
+
def create(name, collection:, events:, filter: nil, enabled: nil, log_ok_invocations: nil)
|
|
119
|
+
unless collection.is_a?(String) && COLLECTION_RE.match?(collection)
|
|
120
|
+
raise ApprovalChecks.invalid('collection must match ^[a-zA-Z_][a-zA-Z0-9_]*$ (1 to 128 characters)',
|
|
121
|
+
collection: collection)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
body = { 'collection' => collection, 'events' => check_events(events) }
|
|
125
|
+
body['filter'] = filter unless filter.nil?
|
|
126
|
+
body['enabled'] = enabled unless enabled.nil?
|
|
127
|
+
body['logOkInvocations'] = log_ok_invocations unless log_ok_invocations.nil?
|
|
128
|
+
call('POST', path(name), body: body)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Change a trigger; send at least one of +events+, +filter+, +enabled+, +log_ok_invocations+. A Hash +filter+
|
|
132
|
+
# replaces the filter and an explicit +filter: nil+ clears it. The collection cannot be changed.
|
|
133
|
+
def update(name, trigger_id, **fields)
|
|
134
|
+
unknown = fields.keys - UPDATE_FIELDS
|
|
135
|
+
raise ApprovalChecks.invalid("unknown field: #{unknown.join(', ')}") unless unknown.empty?
|
|
136
|
+
raise ApprovalChecks.invalid("patch needs at least one of: #{UPDATE_FIELDS.join(', ')}") if fields.empty?
|
|
137
|
+
|
|
138
|
+
body = {}
|
|
139
|
+
body['events'] = check_events(fields[:events]) if fields.key?(:events)
|
|
140
|
+
body['filter'] = fields[:filter] if fields.key?(:filter)
|
|
141
|
+
body['enabled'] = fields[:enabled] if fields.key?(:enabled)
|
|
142
|
+
body['logOkInvocations'] = fields[:log_ok_invocations] if fields.key?(:log_ok_invocations)
|
|
143
|
+
call('PATCH', path(name, "/#{ApprovalChecks.id_segment(trigger_id, 'trigger')}"), body: body)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Delete a trigger. Answers 204, so +data+ is nil; deleting again is a 404.
|
|
147
|
+
def delete(name, trigger_id)
|
|
148
|
+
call('DELETE', path(name, "/#{ApprovalChecks.id_segment(trigger_id, 'trigger')}"))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def check_events(events)
|
|
154
|
+
list = events.is_a?(Array) ? events.map { |e| e.is_a?(Symbol) ? e.to_s : e } : nil
|
|
155
|
+
return list if list && list.size.between?(1, 4) && list.all? { |e| EVENTS.include?(e) }
|
|
156
|
+
|
|
157
|
+
raise ApprovalChecks.invalid('events must list 1 to 4 of insert, update, delete, upsert', events: events)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def call(method, route, body: nil)
|
|
161
|
+
HttpTransport.request(config: @config, method: method, path: route, body: body)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def path(name, tail = '')
|
|
165
|
+
slug = Studio.require_project_slug(@config, 'functions')
|
|
166
|
+
UrlGrammar.project_path(slug, "functions/#{ApprovalChecks.id_segment(name, 'function')}/triggers#{tail}")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
51
169
|
end
|
data/lib/k9o/gate.rb
CHANGED
|
@@ -1,64 +1,94 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module K9o
|
|
4
|
-
# Gate (Identity-Aware Kernel) namespace. Project-scoped.
|
|
4
|
+
# Gate (Identity-Aware Kernel) namespace. Project-scoped; the gateway closes each body to the fields shown.
|
|
5
5
|
class Gate
|
|
6
6
|
def initialize(config)
|
|
7
7
|
@config = config
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
+
# POST /@<slug>/gate/signup { email, password, displayName? }
|
|
10
11
|
def sign_up(email:, password:, display_name: nil)
|
|
11
12
|
body = { 'email' => email, 'password' => password }
|
|
12
13
|
body['displayName'] = display_name if display_name
|
|
13
14
|
HttpTransport.request(config: @config, method: 'POST', path: gate_path('signup'), body: body)
|
|
14
15
|
end
|
|
15
16
|
|
|
17
|
+
# POST /@<slug>/gate/signin { email, password }
|
|
18
|
+
#
|
|
19
|
+
# Asks the project which region holds the email first (POST /@<slug>/gate/locate, the email only, no credential),
|
|
20
|
+
# then sends the password to that region's host if it is a host of your own org and apex, else to the base URL.
|
|
21
|
+
# A 429 or 503 from that step raises as any gateway error does, nothing else is sent, and it is safe to retry.
|
|
16
22
|
def sign_in(email:, password:)
|
|
17
|
-
|
|
23
|
+
origin = Locate.origin_for(@config, email, gate_path('locate'))
|
|
24
|
+
out = HttpTransport.request(
|
|
18
25
|
config: @config, method: 'POST', path: gate_path('signin'),
|
|
19
|
-
body: { 'email' => email, 'password' => password }
|
|
26
|
+
body: { 'email' => email, 'password' => password }, origin: origin
|
|
20
27
|
)
|
|
28
|
+
Locate.remember(session_refresh_token(out), origin)
|
|
29
|
+
out
|
|
21
30
|
end
|
|
22
31
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'),
|
|
32
|
+
# POST /@<slug>/gate/signout { refreshToken } — the refresh token is required (the gateway answers 400 without it).
|
|
33
|
+
def sign_out(refresh_token:)
|
|
34
|
+
out = HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'),
|
|
35
|
+
body: { 'refreshToken' => refresh_token }, origin: Locate.hint(refresh_token))
|
|
36
|
+
Locate.forget(refresh_token)
|
|
37
|
+
out
|
|
26
38
|
end
|
|
27
39
|
|
|
40
|
+
# GET /@<slug>/gate/whoami
|
|
28
41
|
def whoami
|
|
29
42
|
HttpTransport.request(config: @config, method: 'GET', path: gate_path('whoami'))
|
|
30
43
|
end
|
|
31
44
|
|
|
45
|
+
# POST /@<slug>/auth/sessions/revoke-all
|
|
46
|
+
#
|
|
47
|
+
# Ends every live session of the signed-in end user, on every device, this one included. Call it with the end
|
|
48
|
+
# user's access token as the client's `api_key` (the token `whoami` uses), not a refresh token. The answer's `data`
|
|
49
|
+
# is `{ revokedCount }`. Each ended session's next refresh raises a KError with code `K9O_SESSION_EXPIRED` and a
|
|
50
|
+
# `reason` of `"revoked"` in its `context`; access tokens already issued keep working until they expire. An
|
|
51
|
+
# operator's token raises `K9O_TRUST_ZONE_VIOLATION` (403), and a token issued for another project
|
|
52
|
+
# `K9O_PROJECT_SCOPE_MISMATCH` (403).
|
|
53
|
+
def revoke_all_sessions
|
|
54
|
+
HttpTransport.request(config: @config, method: 'POST', path: project_path('auth/sessions/revoke-all'))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# POST /@<slug>/gate/refresh { refreshToken }
|
|
58
|
+
#
|
|
59
|
+
# The answer's `data` is `{ accessToken, refreshToken, expiresAt }`. The refresh token you sent stops working: store
|
|
60
|
+
# the returned `refreshToken` before the next refresh, and refresh one at a time per session. A second call with a
|
|
61
|
+
# secret that was already used is a replay: the session is revoked and the call raises a KError with code
|
|
62
|
+
# `K9O_SESSION_EXPIRED` and a `reason` of `"replay"` in its `context`. The newer secret then gets `"revoked"`. The
|
|
63
|
+
# other reasons are `"inactive"` and `"max_length"`; sign in again.
|
|
32
64
|
def refresh_token(refresh_token:)
|
|
33
|
-
HttpTransport.request(
|
|
65
|
+
out = HttpTransport.request(
|
|
34
66
|
config: @config, method: 'POST', path: gate_path('refresh'),
|
|
35
|
-
body: { 'refreshToken' => refresh_token }
|
|
67
|
+
body: { 'refreshToken' => refresh_token }, origin: Locate.hint(refresh_token)
|
|
36
68
|
)
|
|
69
|
+
Locate.rotate(refresh_token, out.respond_to?(:data) ? out.data : nil)
|
|
70
|
+
out
|
|
37
71
|
end
|
|
38
72
|
|
|
39
|
-
|
|
40
|
-
HttpTransport.request(config: @config, method: 'GET', path: gate_path('verify'))
|
|
41
|
-
end
|
|
73
|
+
private
|
|
42
74
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# an unknown org, a deleted project and a non-Gate project all answer the
|
|
48
|
-
# same 404 K9O_PROJECT_NOT_FOUND.
|
|
49
|
-
def hosted
|
|
50
|
-
HttpTransport.request(config: @config, method: 'GET', path: gate_path('hosted'), authenticated: false)
|
|
75
|
+
def session_refresh_token(envelope)
|
|
76
|
+
data = envelope.respond_to?(:data) ? envelope.data : nil
|
|
77
|
+
session = data.is_a?(Hash) ? data['session'] : nil
|
|
78
|
+
session.is_a?(Hash) ? session['refreshToken'] : nil
|
|
51
79
|
end
|
|
52
80
|
|
|
53
|
-
private
|
|
54
|
-
|
|
55
81
|
def gate_path(action)
|
|
82
|
+
project_path("gate/#{action}")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def project_path(suffix)
|
|
56
86
|
raise KError.new(
|
|
57
87
|
code: 'K9O_MISSING_PROJECT_CONTEXT',
|
|
58
88
|
message: 'k9o.gate operations require project_slug bound on the client',
|
|
59
89
|
context: {}
|
|
60
90
|
) if @config.project_slug.nil? || @config.project_slug.empty?
|
|
61
|
-
UrlGrammar.project_path(@config.project_slug,
|
|
91
|
+
UrlGrammar.project_path(@config.project_slug, suffix)
|
|
62
92
|
end
|
|
63
93
|
end
|
|
64
94
|
end
|
data/lib/k9o/http_transport.rb
CHANGED
|
@@ -29,12 +29,13 @@ module K9o
|
|
|
29
29
|
# Per-instance HTTP config bound on K9oClient.
|
|
30
30
|
class HttpClientConfig
|
|
31
31
|
attr_reader :base_url, :api_key, :org_slug, :project_slug, :default_headers, :http_client, :retry_config,
|
|
32
|
-
:region_mutex
|
|
32
|
+
:region_mutex, :expected_key_refs
|
|
33
33
|
# Cached regional origin (see K9o::Region); nil until a definite answer.
|
|
34
34
|
attr_accessor :regional_origin
|
|
35
35
|
|
|
36
36
|
def initialize(base_url:, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil,
|
|
37
|
-
retry_config: nil)
|
|
37
|
+
retry_config: nil, expected_key_refs: nil)
|
|
38
|
+
@expected_key_refs = expected_key_refs
|
|
38
39
|
@base_url = base_url
|
|
39
40
|
@api_key = api_key
|
|
40
41
|
@org_slug = org_slug
|
|
@@ -49,6 +50,8 @@ module K9o
|
|
|
49
50
|
|
|
50
51
|
# HTTP transport using Net::HTTP stdlib.
|
|
51
52
|
module HttpTransport
|
|
53
|
+
# Seconds to wait for a response, as in the Python SDK (120 s read). A long call, such as Rampart `classify` on a long input, can run about 20 s.
|
|
54
|
+
DEFAULT_READ_TIMEOUT = 120
|
|
52
55
|
RETRYABLE_METHODS = %w[GET HEAD OPTIONS DELETE].freeze
|
|
53
56
|
RETRYABLE_STATUS = [502, 503, 504, 429].freeze
|
|
54
57
|
|
|
@@ -68,13 +71,32 @@ module K9o
|
|
|
68
71
|
#
|
|
69
72
|
# `authenticated: false` sends NO credential: no api_key bearer, and any
|
|
70
73
|
# authorization / cookie header from `default_headers` or `headers:` is dropped.
|
|
71
|
-
|
|
74
|
+
#
|
|
75
|
+
# `origin:` sends the request to that origin instead of the client's regional base. Only the end-user credential
|
|
76
|
+
# flows set it, after the rule in K9o::Locate.
|
|
77
|
+
#
|
|
78
|
+
# `bare: true` is for the few public feeds that answer `{ ...fields, meta }` without a `data` wrapper (the Rampart
|
|
79
|
+
# key feed): the fields come back as `data`.
|
|
80
|
+
#
|
|
81
|
+
# `binary: true` is for download routes: a 2xx answer comes back as a K9o::Download (bytes and content type),
|
|
82
|
+
# not parsed; an error answer is still the JSON error envelope.
|
|
83
|
+
def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
|
|
84
|
+
raw_body: nil, bare: false, binary: false, origin: nil)
|
|
72
85
|
retry_with_backoff(method, config.retry_config || RetryConfig.default) do
|
|
73
86
|
request_once(config: config, method: method, path: path, body: body, query: query, headers: headers,
|
|
74
|
-
authenticated: authenticated, multipart: multipart
|
|
87
|
+
authenticated: authenticated, multipart: multipart, raw_body: raw_body, bare: bare,
|
|
88
|
+
binary: binary, origin: origin)
|
|
75
89
|
end
|
|
76
90
|
end
|
|
77
91
|
|
|
92
|
+
# A response header, or nil (also for response doubles that have no header table).
|
|
93
|
+
def header_of(response, name)
|
|
94
|
+
value = response[name]
|
|
95
|
+
value.is_a?(String) ? value : nil
|
|
96
|
+
rescue NameError, IndexError, TypeError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
78
100
|
# Run a block with retry-with-backoff. Extracted so the retry policy is
|
|
79
101
|
# unit-testable without a live HTTP round-trip.
|
|
80
102
|
def retry_with_backoff(method, retry_config)
|
|
@@ -116,8 +138,9 @@ module K9o
|
|
|
116
138
|
end
|
|
117
139
|
|
|
118
140
|
# Single-attempt request. `request` wraps this in retry-with-backoff.
|
|
119
|
-
def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil
|
|
120
|
-
|
|
141
|
+
def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
|
|
142
|
+
raw_body: nil, bare: false, binary: false, origin: nil)
|
|
143
|
+
url = "#{(origin || Region.resolve_origin(config)).sub(%r{/\z}, '')}#{path}"
|
|
121
144
|
if query && !query.empty?
|
|
122
145
|
url += '?' + URI.encode_www_form(query)
|
|
123
146
|
end
|
|
@@ -125,7 +148,7 @@ module K9o
|
|
|
125
148
|
uri = URI.parse(url)
|
|
126
149
|
http = config.http_client || ::Net::HTTP.new(uri.host, uri.port)
|
|
127
150
|
http.use_ssl = (uri.scheme == 'https')
|
|
128
|
-
http.read_timeout =
|
|
151
|
+
http.read_timeout = DEFAULT_READ_TIMEOUT
|
|
129
152
|
|
|
130
153
|
req_class = case method.to_s.upcase
|
|
131
154
|
when 'GET' then ::Net::HTTP::Get
|
|
@@ -149,7 +172,10 @@ module K9o
|
|
|
149
172
|
req.delete('cookie')
|
|
150
173
|
end
|
|
151
174
|
|
|
152
|
-
if
|
|
175
|
+
if raw_body
|
|
176
|
+
# Binary routes: the bytes go unchanged; the caller supplies the content-type in +headers+.
|
|
177
|
+
req.body = raw_body
|
|
178
|
+
elsif multipart
|
|
153
179
|
# multipart/form-data: the encoder sets the boundary; never force JSON.
|
|
154
180
|
data, content_type = Multipart.encode(multipart)
|
|
155
181
|
req['content-type'] = content_type
|
|
@@ -174,6 +200,10 @@ module K9o
|
|
|
174
200
|
|
|
175
201
|
return Envelope.new(data: nil, meta: Meta.new) if status == 204
|
|
176
202
|
|
|
203
|
+
if binary && status >= 200 && status < 300
|
|
204
|
+
return Download.new(body: (response.body || '').b, content_type: header_of(response, 'content-type'))
|
|
205
|
+
end
|
|
206
|
+
|
|
177
207
|
begin
|
|
178
208
|
parsed = JSON.parse(response.body || '')
|
|
179
209
|
rescue JSON::ParserError => e
|
|
@@ -181,11 +211,17 @@ module K9o
|
|
|
181
211
|
code: 'K9O_INTERNAL_ERROR',
|
|
182
212
|
message: "Failed to parse JSON response from #{url}: #{e.message}",
|
|
183
213
|
context: { url: url, status: status },
|
|
184
|
-
status: status
|
|
214
|
+
status: status,
|
|
215
|
+
request_id: KError.request_id_from_header(header_of(response, 'x-k9o-request-id'))
|
|
185
216
|
)
|
|
186
217
|
end
|
|
187
218
|
|
|
188
219
|
if status >= 200 && status < 300
|
|
220
|
+
if bare && parsed.is_a?(Hash)
|
|
221
|
+
return Envelope.new(data: parsed.reject { |key, _| key == 'meta' },
|
|
222
|
+
meta: Meta.from_wire(parsed['meta'].is_a?(Hash) ? parsed['meta'] : {}))
|
|
223
|
+
end
|
|
224
|
+
|
|
189
225
|
unless parsed.is_a?(Hash) && parsed.key?('data') && parsed.key?('meta')
|
|
190
226
|
raise KError.new(
|
|
191
227
|
code: 'K9O_INTERNAL_ERROR',
|
|
@@ -198,14 +234,16 @@ module K9o
|
|
|
198
234
|
end
|
|
199
235
|
|
|
200
236
|
if parsed.is_a?(Hash) && parsed['code'].is_a?(String) && parsed['message'].is_a?(String)
|
|
201
|
-
raise KError.from_wire(parsed, status)
|
|
237
|
+
raise KError.from_wire(parsed, status, retry_after_header: header_of(response, 'retry-after'),
|
|
238
|
+
request_id_header: header_of(response, 'x-k9o-request-id'))
|
|
202
239
|
end
|
|
203
240
|
|
|
204
241
|
raise KError.new(
|
|
205
242
|
code: 'K9O_INTERNAL_ERROR',
|
|
206
243
|
message: "Upstream returned #{status} with non-KError body",
|
|
207
244
|
context: { url: url, status: status },
|
|
208
|
-
status: status
|
|
245
|
+
status: status,
|
|
246
|
+
request_id: KError.request_id_from_header(header_of(response, 'x-k9o-request-id'))
|
|
209
247
|
)
|
|
210
248
|
end
|
|
211
249
|
|
|
@@ -215,9 +253,9 @@ module K9o
|
|
|
215
253
|
# sentinel terminates the stream; non-2xx raises KError before any
|
|
216
254
|
# event is yielded.
|
|
217
255
|
#
|
|
218
|
-
#
|
|
256
|
+
# The Ruby async-iteration idiom is Enumerator — caller
|
|
219
257
|
# writes `client.compute.llms.invoke_stream(...).each { |chunk| ... }`.
|
|
220
|
-
def stream_sse(config:, method:, path:, body: nil, query: nil)
|
|
258
|
+
def stream_sse(config:, method:, path:, body: nil, query: nil, headers: nil, idle_timeout: nil, raw: false)
|
|
221
259
|
url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
|
|
222
260
|
if query && !query.empty?
|
|
223
261
|
url += '?' + URI.encode_www_form(query)
|
|
@@ -227,7 +265,8 @@ module K9o
|
|
|
227
265
|
Enumerator.new do |yielder|
|
|
228
266
|
http = config.http_client || ::Net::HTTP.new(uri.host, uri.port)
|
|
229
267
|
http.use_ssl = (uri.scheme == 'https')
|
|
230
|
-
|
|
268
|
+
# The read timeout is a per-read idle limit, not a total limit on the stream.
|
|
269
|
+
http.read_timeout = idle_timeout || 300
|
|
231
270
|
|
|
232
271
|
req_class = case method.to_s.upcase
|
|
233
272
|
when 'GET' then ::Net::HTTP::Get
|
|
@@ -236,6 +275,7 @@ module K9o
|
|
|
236
275
|
end
|
|
237
276
|
req = req_class.new(uri.request_uri)
|
|
238
277
|
req['accept'] = 'text/event-stream'
|
|
278
|
+
headers&.each { |k, v| req[k] = v }
|
|
239
279
|
config.default_headers.each { |k, v| req[k] = v }
|
|
240
280
|
req['x-k9o-project-slug'] = config.project_slug if config.project_slug
|
|
241
281
|
req['x-k9o-org-slug'] = config.org_slug if config.org_slug
|
|
@@ -257,20 +297,34 @@ module K9o
|
|
|
257
297
|
nil
|
|
258
298
|
end
|
|
259
299
|
if parsed.is_a?(Hash) && parsed['code'].is_a?(String) && parsed['message'].is_a?(String)
|
|
260
|
-
raise KError.from_wire(parsed, status)
|
|
300
|
+
raise KError.from_wire(parsed, status, retry_after_header: header_of(response, 'retry-after'),
|
|
301
|
+
request_id_header: header_of(response, 'x-k9o-request-id'))
|
|
261
302
|
end
|
|
262
303
|
|
|
263
304
|
raise KError.new(
|
|
264
305
|
code: 'K9O_INTERNAL_ERROR',
|
|
265
306
|
message: "Upstream returned #{status} opening stream",
|
|
266
307
|
context: { url: url, status: status },
|
|
267
|
-
status: status
|
|
308
|
+
status: status,
|
|
309
|
+
request_id: KError.request_id_from_header(header_of(response, 'x-k9o-request-id'))
|
|
268
310
|
)
|
|
269
311
|
end
|
|
270
312
|
|
|
271
313
|
buffer = +''
|
|
272
314
|
data_lines = []
|
|
315
|
+
event_name = nil
|
|
316
|
+
event_id = nil
|
|
317
|
+
retry_ms = nil
|
|
273
318
|
flush = lambda do
|
|
319
|
+
if raw
|
|
320
|
+
# Raw mode yields every frame as { event:, id:, data:, retry: } (comment lines are dropped).
|
|
321
|
+
had = !data_lines.empty? || event_name || event_id || retry_ms
|
|
322
|
+
frame = { event: event_name, id: event_id, data: data_lines.empty? ? nil : data_lines.join("\n"), retry: retry_ms }
|
|
323
|
+
data_lines = []
|
|
324
|
+
event_name = event_id = retry_ms = nil
|
|
325
|
+
yielder << frame if had
|
|
326
|
+
next true
|
|
327
|
+
end
|
|
274
328
|
return true if data_lines.empty?
|
|
275
329
|
|
|
276
330
|
payload = data_lines.join("\n")
|
|
@@ -297,6 +351,14 @@ module K9o
|
|
|
297
351
|
rest = line[5..]
|
|
298
352
|
rest = rest[1..] if rest.start_with?(' ')
|
|
299
353
|
data_lines << rest
|
|
354
|
+
elsif raw && line.start_with?('event:', 'id:', 'retry:')
|
|
355
|
+
field, rest = line.split(':', 2)
|
|
356
|
+
rest = rest[1..] if rest.start_with?(' ')
|
|
357
|
+
case field
|
|
358
|
+
when 'event' then event_name = rest
|
|
359
|
+
when 'id' then event_id = rest
|
|
360
|
+
else retry_ms = Integer(rest, exception: false)
|
|
361
|
+
end
|
|
300
362
|
end
|
|
301
363
|
end
|
|
302
364
|
end
|
data/lib/k9o/jcs.rb
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# The platform's canonical JSON form (the Kilimanjaro JCS code-point profile), implemented here with no dependency.
|
|
5
|
+
#
|
|
6
|
+
# K9o::Jcs.canonicalize({ "b" => 2, "a" => [1, 3] }) # => '{"a":[1,3],"b":2}'
|
|
7
|
+
#
|
|
8
|
+
# Object members are sorted by UTF-8 byte order (the same as Unicode code-point order), there is no whitespace,
|
|
9
|
+
# strings are escaped minimally (+"+ and +\+, the short escapes for backspace, tab, newline, form feed and carriage
|
|
10
|
+
# return, every other control character as lowercase +\u00xx+; +/+ and every other character are written as they
|
|
11
|
+
# are), and numbers are integers only: base 10, no exponent, +-0+ written +0+. A fractional number, NaN, an infinity
|
|
12
|
+
# or an integer whose absolute value is above 2^53 - 1 raises, so a value that cannot be hashed identically
|
|
13
|
+
# everywhere never gets a hash.
|
|
14
|
+
module Jcs
|
|
15
|
+
# Raised for a value that has no canonical form. +kind+ is :non_integer_number, :numeric_ceiling_exceeded or
|
|
16
|
+
# :unsupported_value.
|
|
17
|
+
class Error < ArgumentError
|
|
18
|
+
attr_reader :kind
|
|
19
|
+
|
|
20
|
+
def initialize(message, kind = :unsupported_value)
|
|
21
|
+
super(message)
|
|
22
|
+
@kind = kind
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
ESCAPES = {
|
|
27
|
+
'"' => '\\"', '\\' => '\\\\', "\b" => '\\b', "\t" => '\\t', "\n" => '\\n', "\f" => '\\f', "\r" => '\\r'
|
|
28
|
+
}.freeze
|
|
29
|
+
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# The canonical form of +value+ as a UTF-8 String.
|
|
34
|
+
def canonicalize(value)
|
|
35
|
+
out = +''
|
|
36
|
+
write(value, out)
|
|
37
|
+
out.force_encoding(Encoding::UTF_8)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The canonical form as bytes (UTF-8), the preimage to hash.
|
|
41
|
+
def canonical_bytes(value)
|
|
42
|
+
canonicalize(value).b
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def write(value, out)
|
|
46
|
+
case value
|
|
47
|
+
when nil then out << 'null'
|
|
48
|
+
when true then out << 'true'
|
|
49
|
+
when false then out << 'false'
|
|
50
|
+
when Integer then out << number(value)
|
|
51
|
+
when Float then out << number(value)
|
|
52
|
+
when String then write_string(value, out)
|
|
53
|
+
when Symbol then write_string(value.to_s, out)
|
|
54
|
+
when Array
|
|
55
|
+
out << '['
|
|
56
|
+
value.each_with_index do |item, index|
|
|
57
|
+
out << ',' if index.positive?
|
|
58
|
+
write(item, out)
|
|
59
|
+
end
|
|
60
|
+
out << ']'
|
|
61
|
+
when Hash then write_object(value, out)
|
|
62
|
+
else
|
|
63
|
+
raise Error, "#{value.class} has no JSON form"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def write_object(hash, out)
|
|
68
|
+
members = hash.map { |key, val| [key.is_a?(Symbol) ? key.to_s : key, val] }
|
|
69
|
+
members.each do |key, _|
|
|
70
|
+
raise Error, 'object keys must be strings' unless key.is_a?(String)
|
|
71
|
+
end
|
|
72
|
+
keys = members.map(&:first)
|
|
73
|
+
raise Error, 'object has duplicate keys' unless keys.uniq.size == keys.size
|
|
74
|
+
|
|
75
|
+
sorted = members.sort_by { |key, _| key.b }
|
|
76
|
+
out << '{'
|
|
77
|
+
sorted.each_with_index do |(key, val), index|
|
|
78
|
+
out << ',' if index.positive?
|
|
79
|
+
write_string(key, out)
|
|
80
|
+
out << ':'
|
|
81
|
+
write(val, out)
|
|
82
|
+
end
|
|
83
|
+
out << '}'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def write_string(string, out)
|
|
87
|
+
text = string.encoding == Encoding::UTF_8 ? string : string.encode(Encoding::UTF_8)
|
|
88
|
+
raise Error, 'string is not valid UTF-8' unless text.valid_encoding?
|
|
89
|
+
|
|
90
|
+
out << '"'
|
|
91
|
+
text.each_char do |ch|
|
|
92
|
+
if (esc = ESCAPES[ch])
|
|
93
|
+
out << esc
|
|
94
|
+
elsif ch.ord < 0x20
|
|
95
|
+
out << format('\\u%04x', ch.ord)
|
|
96
|
+
else
|
|
97
|
+
out << ch
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
out << '"'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Integers only, within +/-(2^53 - 1). A float that is a whole number is written as that integer (a JSON reader
|
|
104
|
+
# that parses +3.0+ sees the integer 3).
|
|
105
|
+
def number(num)
|
|
106
|
+
if num.is_a?(Float)
|
|
107
|
+
raise Error.new('NaN and Infinity have no canonical form', :non_integer_number) unless num.finite?
|
|
108
|
+
raise Error.new('a fractional number has no canonical form', :non_integer_number) unless num == num.floor
|
|
109
|
+
|
|
110
|
+
num = num.to_i
|
|
111
|
+
end
|
|
112
|
+
raise Error.new("integer #{num} is above 2^53 - 1", :numeric_ceiling_exceeded) if num.abs > MAX_SAFE_INTEGER
|
|
113
|
+
|
|
114
|
+
num.to_s
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|