k9o 1.1.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 +10 -0
- data/LICENSE +201 -0
- data/NOTICE +4 -0
- data/README.md +546 -0
- data/k9o.gemspec +41 -0
- data/lib/k9o/ai.rb +28 -0
- data/lib/k9o/audit.rb +29 -0
- data/lib/k9o/barbican.rb +239 -0
- data/lib/k9o/client.rb +59 -0
- data/lib/k9o/compute.rb +67 -0
- data/lib/k9o/data.rb +157 -0
- data/lib/k9o/envelope.rb +64 -0
- data/lib/k9o/functions.rb +51 -0
- data/lib/k9o/gate.rb +64 -0
- data/lib/k9o/http_transport.rb +318 -0
- data/lib/k9o/kerror.rb +52 -0
- data/lib/k9o/kibo.rb +114 -0
- data/lib/k9o/me.rb +160 -0
- data/lib/k9o/migrations.rb +244 -0
- data/lib/k9o/multipart.rb +42 -0
- data/lib/k9o/org.rb +213 -0
- data/lib/k9o/orgs.rb +33 -0
- data/lib/k9o/policies.rb +65 -0
- data/lib/k9o/projects.rb +33 -0
- data/lib/k9o/realtime.rb +80 -0
- data/lib/k9o/region.rb +95 -0
- data/lib/k9o/storage.rb +176 -0
- data/lib/k9o/studio.rb +175 -0
- data/lib/k9o/url_grammar.rb +113 -0
- data/lib/k9o/vault.rb +235 -0
- data/lib/k9o/version.rb +5 -0
- data/lib/k9o.rb +54 -0
- data/sig/k9o/ai.rbs +16 -0
- data/sig/k9o/gate.rbs +11 -0
- data/sig/k9o/migrations.rbs +138 -0
- data/sig/k9o/region.rbs +19 -0
- data/sig/k9o/storage.rbs +41 -0
- metadata +98 -0
data/lib/k9o/barbican.rb
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'kerror'
|
|
4
|
+
|
|
5
|
+
module K9o
|
|
6
|
+
# Barbican — free, embeddable L2 guardrail. Input normalization -> Aho-Corasick
|
|
7
|
+
# deny-list matching over a signed ruleset -> local verdict, plus a streaming
|
|
8
|
+
# output redactor (buffer-and-scan-before-flush).
|
|
9
|
+
#
|
|
10
|
+
# Thin wrapper over the bound core (`k9o-keep-core`, via uniffi) — mirrors the
|
|
11
|
+
# structural pattern of +K9o::Kibo+ (native-load guard evaluated once at
|
|
12
|
+
# require time; every call forwards typed arguments to the generated binding
|
|
13
|
+
# and maps its error -> +K9o::KError+). ZERO guardrail logic lives here: every
|
|
14
|
+
# normalization pass, deny match, verdict, and redaction decision is the
|
|
15
|
+
# audited native core's.
|
|
16
|
+
#
|
|
17
|
+
# CLAIM BOUNDARY: Barbican is advisory / defense-in-depth (edge filtration).
|
|
18
|
+
# It is never the enforced sandbox boundary.
|
|
19
|
+
#
|
|
20
|
+
# The generated uniffi binding + native lib (+lib/k9o/_ffi/+) are build
|
|
21
|
+
# artifacts (CI-injected per platform; gitignored) — on a platform/Ruby
|
|
22
|
+
# without them, +require+ still succeeds and +Barbican.new+ raises a clear
|
|
23
|
+
# +K9o::KError+, so loading +k9o+ never breaks for the rest of the SDK.
|
|
24
|
+
class Barbican
|
|
25
|
+
# Attempt to load the native binding; never raises at require time.
|
|
26
|
+
NATIVE_AVAILABLE =
|
|
27
|
+
begin
|
|
28
|
+
require_relative '_ffi/k9o_keep_core_ffi_uniffi'
|
|
29
|
+
true
|
|
30
|
+
rescue LoadError
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# +decode_depth+ of 0 uses the core default (3) — a bounded base64/leetspeak
|
|
35
|
+
# de-obfuscation depth (decode-bomb guard).
|
|
36
|
+
def initialize(fail_open: false, leet: true, decode_depth: 0)
|
|
37
|
+
self.class.ensure_native!
|
|
38
|
+
config = K9oKeepCoreFfiUniffi::KeepConfig.new(
|
|
39
|
+
fail_open: fail_open, leet: leet, decode_depth: decode_depth
|
|
40
|
+
)
|
|
41
|
+
@core = K9oKeepCoreFfiUniffi::KeepCore.new(config)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Load a static ruleset bundled with your gem (Mode-A path: provenance is
|
|
45
|
+
# vouched for by your package, so signature verification is skipped — the
|
|
46
|
+
# core still enforces anti-rollback, never loading a version <= the current
|
|
47
|
+
# one). +rules+ = an array of {id:, pattern:, severity:} (severity one of
|
|
48
|
+
# low|medium|high|critical; string or symbol keys both accepted). Returns
|
|
49
|
+
# the accepted version.
|
|
50
|
+
def load_ruleset(ruleset_version, rules)
|
|
51
|
+
blob = K9oKeepCoreFfiUniffi::KeepRulesetBlob.new(
|
|
52
|
+
ruleset_version: ruleset_version,
|
|
53
|
+
rules: rules.map { |r| build_rule(r) }
|
|
54
|
+
)
|
|
55
|
+
begin
|
|
56
|
+
@core.load_ruleset_unsigned(blob)
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
raise self.class.to_kerror(e)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Load a Barbican key manifest (compact JWS, +typ k9o.barbican.keys.v1+). The
|
|
63
|
+
# core checks it against the root key compiled into it and, only if it
|
|
64
|
+
# verifies, REPLACES the trusted ruleset-signing keys with the ones it lists.
|
|
65
|
+
# A refusal (wrong signer, rollback, a key changing its public key, a
|
|
66
|
+
# malformed entry) raises +K9O_BARBICAN_RULESET_INVALID+ with
|
|
67
|
+
# +context[:stage] == 'key_manifest'+ and leaves the trusted keys unchanged.
|
|
68
|
+
# Returns the manifest +sequence+.
|
|
69
|
+
def load_key_manifest(jws, now_secs = nil)
|
|
70
|
+
@core.load_key_manifest(jws, now_secs || Time.now.to_i)
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
raise self.class.to_kerror(e, 'key_manifest')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Verify a ruleset manifest (compact JWS) under a trusted key and return its
|
|
76
|
+
# authenticated fields (+ruleset_version+ / +signing_key_kid+ /
|
|
77
|
+
# +artifact_hash+ / +artifact_url+ / +valid_until+).
|
|
78
|
+
def verify_manifest(jws)
|
|
79
|
+
@core.verify_manifest(jws)
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
raise self.class.to_kerror(e)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Load a signed ruleset. The core verifies the Ed25519 signature ITSELF, over
|
|
85
|
+
# its own canonical bytes, under a key from the loaded key manifest: there is
|
|
86
|
+
# no key or verifier to pass. Any failure (no key manifest loaded, unknown
|
|
87
|
+
# key, bad or non-canonical signature, stale version, malformed rules) raises
|
|
88
|
+
# +K9O_BARBICAN_RULESET_INVALID+ with +context[:reason]+ and leaves the
|
|
89
|
+
# current ruleset running. +signature+ is the raw 64-byte binary String.
|
|
90
|
+
# Returns the accepted version.
|
|
91
|
+
def load_ruleset_signed(ruleset_version, rules, signing_key_kid, signature, created_at = 0)
|
|
92
|
+
@core.load_ruleset_signed(signed_blob(ruleset_version, rules, signing_key_kid, signature, created_at))
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
raise self.class.to_kerror(e)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Lowercase hex SHA-256 of a ruleset's canonical signing bytes — the value a
|
|
98
|
+
# manifest's +artifact_hash+ names. A digest only; it decides no trust.
|
|
99
|
+
def ruleset_digest(ruleset_version, rules, signing_key_kid, signature, created_at = 0)
|
|
100
|
+
@core.ruleset_digest(signed_blob(ruleset_version, rules, signing_key_kid, signature, created_at))
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
raise self.class.to_kerror(e)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The +sequence+ of the key manifest in force (0 before one is loaded).
|
|
106
|
+
def key_manifest_sequence
|
|
107
|
+
@core.key_manifest_sequence
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The highest VERIFIED ruleset version loaded (0 = none): the anti-rollback floor for signed updates. A refresh
|
|
111
|
+
# compares a published version against this, not the version in force.
|
|
112
|
+
def signed_floor
|
|
113
|
+
@core.signed_floor
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Whether the ruleset in force came from a verified load. +false+ when none is loaded, or when +load_ruleset+
|
|
117
|
+
# (unsigned, host-vouched) replaced a verified one.
|
|
118
|
+
def active_ruleset_is_signed?
|
|
119
|
+
@core.active_ruleset_is_signed
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The signing kids the loaded key manifest currently trusts, sorted.
|
|
123
|
+
def trusted_kids
|
|
124
|
+
@core.trusted_kids
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The trusted kids the key manifest flags deprecated, sorted.
|
|
128
|
+
def deprecated_kids
|
|
129
|
+
@core.deprecated_kids
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# +false+ on every shipped build. +true+ means this binding trusts the
|
|
133
|
+
# PUBLISHED TEST root and must never be used outside tests.
|
|
134
|
+
def self.uses_test_trust_root?
|
|
135
|
+
ensure_native!
|
|
136
|
+
K9oKeepCoreFfiUniffi.uses_test_trust_root
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Inspect an input (normalize -> deny -> local verdict). Obfuscation is
|
|
140
|
+
# peeled BEFORE matching, so static deny-lists aren't defeated by trivial
|
|
141
|
+
# tricks. +waypoint_class+ is the manifest's meter hint — it NEVER gates the
|
|
142
|
+
# decision. Returns the verdict object (+decision+ / +reason+ / +matched+ /
|
|
143
|
+
# +schema_valid+ / +waypoint_class_hint+ / +fail_mode+ / +ruleset_version+ /
|
|
144
|
+
# +core_version+).
|
|
145
|
+
def inspect(text, waypoint_class: nil)
|
|
146
|
+
@core.inspect_input(text, waypoint_class)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Local L3 stand-in classification ("clear"|"suspicious"|"malicious"). No
|
|
150
|
+
# receipt — a classification is not an enforcement decision.
|
|
151
|
+
def classify(text)
|
|
152
|
+
@core.classify_local(text)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Open an output stream-filter bound to the active ruleset's exfil markers.
|
|
156
|
+
def create_output_filter
|
|
157
|
+
OutputFilter.new(@core.output_filter)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The bound core's semver (audit anchor; rides every verdict too).
|
|
161
|
+
def core_version
|
|
162
|
+
@core.core_version
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Guard: raise a clear KError if the native lib is absent (evaluated BEFORE
|
|
166
|
+
# any reference to the +K9oKeepCoreFfiUniffi+ namespace, which may be
|
|
167
|
+
# undefined).
|
|
168
|
+
def self.ensure_native!
|
|
169
|
+
return if NATIVE_AVAILABLE
|
|
170
|
+
|
|
171
|
+
raise K9o::KError.new(
|
|
172
|
+
code: 'K9O_BARBICAN_WASM_ERROR',
|
|
173
|
+
message: 'barbican native library not available for this platform/Ruby; install the platform-specific k9o gem.'
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Map the bound core's error -> +K9o::KError+. +KeepError::RulesetInvalid+
|
|
178
|
+
# is the only typed variant (bad/stale/malformed ruleset) — its reason token
|
|
179
|
+
# (+version_stale+ / +signature_invalid+ / +malformed+ / an unknown-severity
|
|
180
|
+
# message) rides in +context[:reason]+. Anything else from the native layer
|
|
181
|
+
# (e.g. a Rust panic surfaced as +InternalError+) maps to the generic code.
|
|
182
|
+
def self.to_kerror(e, stage = 'ruleset')
|
|
183
|
+
if e.respond_to?(:reason)
|
|
184
|
+
K9o::KError.new(
|
|
185
|
+
code: 'K9O_BARBICAN_RULESET_INVALID',
|
|
186
|
+
message: e.to_s,
|
|
187
|
+
context: { reason: e.reason, stage: stage }
|
|
188
|
+
)
|
|
189
|
+
else
|
|
190
|
+
K9o::KError.new(code: 'K9O_BARBICAN_WASM_ERROR', message: e.to_s)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Accept string or symbol keys ({"id" => ..} or {id: ..}) for ergonomics.
|
|
195
|
+
def build_rule(r)
|
|
196
|
+
K9oKeepCoreFfiUniffi::KeepRule.new(
|
|
197
|
+
id: r[:id] || r['id'],
|
|
198
|
+
pattern: r[:pattern] || r['pattern'],
|
|
199
|
+
severity: r[:severity] || r['severity']
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
private :build_rule
|
|
203
|
+
|
|
204
|
+
def signed_blob(ruleset_version, rules, signing_key_kid, signature, created_at)
|
|
205
|
+
K9oKeepCoreFfiUniffi::KeepSignedRulesetBlob.new(
|
|
206
|
+
ruleset_version: ruleset_version,
|
|
207
|
+
rules: rules.map { |r| build_rule(r) },
|
|
208
|
+
signing_key_kid: signing_key_kid,
|
|
209
|
+
signature: signature.b,
|
|
210
|
+
created_at: created_at
|
|
211
|
+
)
|
|
212
|
+
end
|
|
213
|
+
private :signed_blob
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# A streaming output redactor. Buffer-and-scan-before-flush: never emits a
|
|
217
|
+
# byte the core has not cleared (no RST-after-leak).
|
|
218
|
+
class OutputFilter
|
|
219
|
+
def initialize(handle)
|
|
220
|
+
@handle = handle
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Feed a chunk; returns the cleared (redacted) prefix safe to emit now.
|
|
224
|
+
def push(chunk)
|
|
225
|
+
@handle.push(chunk)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Flush the residual buffer (also scanned) at end of stream.
|
|
229
|
+
def finish
|
|
230
|
+
@handle.finish
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Leak report (counts only — never the leaked values): an object with
|
|
234
|
+
# +emails+ / +secrets+ / +markers+ / +total+.
|
|
235
|
+
def report
|
|
236
|
+
@handle.report
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
data/lib/k9o/client.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# The customer-facing client returned by K9o.create_client.
|
|
5
|
+
class K9oClient
|
|
6
|
+
attr_reader :config, :projects, :gate, :studio, :compute, :realtime, :org,
|
|
7
|
+
:orgs, :migrations, :functions, :policies, :audit, :me, :kibo, :ai, :storage
|
|
8
|
+
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@config = config
|
|
11
|
+
@projects = Projects.new(config)
|
|
12
|
+
@gate = Gate.new(config)
|
|
13
|
+
@studio = Studio.new(config)
|
|
14
|
+
@compute = Compute.new(config)
|
|
15
|
+
@realtime = Realtime.new(config)
|
|
16
|
+
@org = Org.new(config)
|
|
17
|
+
@orgs = Orgs.new(config)
|
|
18
|
+
@migrations = Migrations.new(config)
|
|
19
|
+
@functions = Functions.new(config)
|
|
20
|
+
@policies = Policies.new(config)
|
|
21
|
+
@audit = Audit.new(config)
|
|
22
|
+
@me = Me.new(config)
|
|
23
|
+
@kibo = KiboNamespace.new(config)
|
|
24
|
+
@ai = Ai.new(config)
|
|
25
|
+
@storage = Storage.new(config)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def from(collection)
|
|
29
|
+
FromBuilder.new(@config, collection)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def base_url
|
|
33
|
+
@config.base_url
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Factory — construct a K9oClient bound to the supplied options.
|
|
38
|
+
#
|
|
39
|
+
# Per spec sample:
|
|
40
|
+
# k9o = K9o.create_client(
|
|
41
|
+
# api_key: ENV["K9O_API_KEY"],
|
|
42
|
+
# org_slug: "acme",
|
|
43
|
+
# project_slug: "member-portal",
|
|
44
|
+
# )
|
|
45
|
+
#
|
|
46
|
+
# base_url is OPTIONAL. When omitted it
|
|
47
|
+
# defaults to the platform apex https://api.k9o.io; when the host is the
|
|
48
|
+
# bare apex and org_slug is set, the canonical org-subdomain wire root
|
|
49
|
+
# https://<org_slug>.api.k9o.io is composed. Any other host passes through.
|
|
50
|
+
def self.create_client(base_url: nil, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil)
|
|
51
|
+
normalised = UrlGrammar.resolve_base_url(base_url, org_slug)
|
|
52
|
+
config = HttpClientConfig.new(
|
|
53
|
+
base_url: normalised,
|
|
54
|
+
api_key: api_key, org_slug: org_slug, project_slug: project_slug,
|
|
55
|
+
default_headers: default_headers, http_client: http_client
|
|
56
|
+
)
|
|
57
|
+
K9oClient.new(config)
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/k9o/compute.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Compute namespace (Compute LLMs registry).
|
|
5
|
+
# k9o.compute.llms.*
|
|
6
|
+
class Compute
|
|
7
|
+
# Curated 9-model closed enum (extensible v2.x).
|
|
8
|
+
LLM_MODEL_IDS = %w[
|
|
9
|
+
mistral-7b mistral-8x7b llama-3-8b llama-3-70b
|
|
10
|
+
claude-3-5-sonnet gpt-4o gpt-4o-mini
|
|
11
|
+
text-embedding-3-small text-embedding-3-large
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :llms
|
|
15
|
+
|
|
16
|
+
def initialize(config)
|
|
17
|
+
@llms = Llms.new(config)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# k9o.compute.llms.*.
|
|
21
|
+
class Llms
|
|
22
|
+
def initialize(config)
|
|
23
|
+
@config = config
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def list(modality: nil)
|
|
27
|
+
slug = Studio.require_project_slug(@config, 'compute.llms')
|
|
28
|
+
q = modality ? { 'modality' => modality } : nil
|
|
29
|
+
HttpTransport.request(config: @config, method: 'GET',
|
|
30
|
+
path: UrlGrammar.project_path(slug, 'compute/llms'), query: q)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read(model_id)
|
|
34
|
+
slug = Studio.require_project_slug(@config, 'compute.llms')
|
|
35
|
+
HttpTransport.request(config: @config, method: 'GET',
|
|
36
|
+
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}"))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Synchronous inference (PROJECT_AUTHOR/ADMIN).
|
|
40
|
+
def invoke(model_id, prompt:, params: nil)
|
|
41
|
+
slug = Studio.require_project_slug(@config, 'compute.llms')
|
|
42
|
+
body = { 'prompt' => prompt }
|
|
43
|
+
body['params'] = params if params
|
|
44
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
45
|
+
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke"), body: body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Streaming inference (SSE per-token). Returns an
|
|
49
|
+
# Enumerator of chunk hashes (Ruby idiom). Consume with
|
|
50
|
+
# `.each { |chunk| ... }`.
|
|
51
|
+
def invoke_stream(model_id, prompt:, params: nil)
|
|
52
|
+
slug = Studio.require_project_slug(@config, 'compute.llms')
|
|
53
|
+
body = { 'prompt' => prompt }
|
|
54
|
+
body['params'] = params if params
|
|
55
|
+
HttpTransport.stream_sse(config: @config, method: 'POST',
|
|
56
|
+
path: UrlGrammar.project_path(slug, "compute/llms/#{enc(model_id)}/invoke-stream"),
|
|
57
|
+
body: body)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def enc(s)
|
|
63
|
+
::URI.encode_www_form_component(s)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/k9o/data.rb
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Data-plane DSL.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
#
|
|
8
|
+
# response = k9o.from('posts').select.eq('status', 'published').limit(10).execute
|
|
9
|
+
# response.data.each { |row| puts row }
|
|
10
|
+
class FromBuilder
|
|
11
|
+
def initialize(config, collection)
|
|
12
|
+
@config = config
|
|
13
|
+
@collection = collection
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def select
|
|
17
|
+
SelectBuilder.new(@config, @collection)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def insert(record)
|
|
21
|
+
HttpTransport.request(
|
|
22
|
+
config: @config, method: 'POST', path: FromBuilder.collection_path(@config, @collection), body: record
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def update(patch)
|
|
27
|
+
UpdateBuilder.new(@config, @collection, patch)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delete
|
|
31
|
+
DeleteBuilder.new(@config, @collection)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.collection_path(config, collection)
|
|
35
|
+
raise KError.new(
|
|
36
|
+
code: 'K9O_MISSING_PROJECT_CONTEXT',
|
|
37
|
+
message: 'data-plane operations require project_slug to be bound on the client',
|
|
38
|
+
context: { collection: collection },
|
|
39
|
+
recommendation: 'Pass project_slug: in K9o.create_client(...).'
|
|
40
|
+
) if config.project_slug.nil? || config.project_slug.empty?
|
|
41
|
+
|
|
42
|
+
unless collection.is_a?(String) && collection.match?(/\A[a-z][a-z0-9_-]{0,62}\z/)
|
|
43
|
+
raise KError.new(
|
|
44
|
+
code: 'K9O_INVALID_FIELD_VALUE',
|
|
45
|
+
message: "collection name #{collection.inspect} does not match required format",
|
|
46
|
+
context: { collection: collection }
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
UrlGrammar.project_path(config.project_slug, "data/#{collection}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Shared filter/pagination state.
|
|
54
|
+
class BaseBuilder
|
|
55
|
+
def initialize(config, collection)
|
|
56
|
+
@config = config
|
|
57
|
+
@collection = collection
|
|
58
|
+
@filters = []
|
|
59
|
+
@limit_val = nil
|
|
60
|
+
@cursor_val = nil
|
|
61
|
+
@count_mode = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
%i[eq neq gt gte lt lte like ilike is].each do |op|
|
|
65
|
+
define_method(op) do |column, value|
|
|
66
|
+
@filters << [column, op.to_s, value.to_s]
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def in_(column, values)
|
|
72
|
+
@filters << [column, 'in', "(#{values.map(&:to_s).join(',')})"]
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def not_in(column, values)
|
|
77
|
+
@filters << [column, 'notIn', "(#{values.map(&:to_s).join(',')})"]
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def filter(column, op, value)
|
|
82
|
+
@filters << [column, op.to_s, value.to_s]
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def limit(n)
|
|
87
|
+
@limit_val = n
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cursor(opaque)
|
|
92
|
+
@cursor_val = opaque
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def count(mode)
|
|
97
|
+
@count_mode = mode
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
protected
|
|
102
|
+
|
|
103
|
+
def to_query
|
|
104
|
+
q = {}
|
|
105
|
+
@filters.each do |col, op, val|
|
|
106
|
+
q[col] = val.start_with?('(') ? "#{op}.#{val}" : "#{op}.#{val}"
|
|
107
|
+
end
|
|
108
|
+
q['limit'] = @limit_val.to_s if @limit_val
|
|
109
|
+
q['cursor'] = @cursor_val if @cursor_val
|
|
110
|
+
q['count'] = @count_mode if @count_mode
|
|
111
|
+
q
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class SelectBuilder < BaseBuilder
|
|
116
|
+
def execute
|
|
117
|
+
HttpTransport.request(
|
|
118
|
+
config: @config, method: 'GET',
|
|
119
|
+
path: FromBuilder.collection_path(@config, @collection),
|
|
120
|
+
query: to_query
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class UpdateBuilder < BaseBuilder
|
|
126
|
+
def initialize(config, collection, patch)
|
|
127
|
+
super(config, collection)
|
|
128
|
+
@patch = patch
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def execute
|
|
132
|
+
id_filter = @filters.find { |col, op, _| col == 'id' && op == 'eq' }
|
|
133
|
+
raise KError.new(
|
|
134
|
+
code: 'K9O_INVALID_FIELD_VALUE',
|
|
135
|
+
message: 'update() requires .eq("id", ...) filter; changing many rows in one call is not supported',
|
|
136
|
+
context: { collection: @collection }
|
|
137
|
+
) unless id_filter
|
|
138
|
+
|
|
139
|
+
path = "#{FromBuilder.collection_path(@config, @collection)}/#{id_filter[2]}"
|
|
140
|
+
HttpTransport.request(config: @config, method: 'PATCH', path: path, body: @patch)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class DeleteBuilder < BaseBuilder
|
|
145
|
+
def execute
|
|
146
|
+
id_filter = @filters.find { |col, op, _| col == 'id' && op == 'eq' }
|
|
147
|
+
raise KError.new(
|
|
148
|
+
code: 'K9O_INVALID_FIELD_VALUE',
|
|
149
|
+
message: 'delete() requires .eq("id", ...) filter; deleting many rows in one call is not supported',
|
|
150
|
+
context: { collection: @collection }
|
|
151
|
+
) unless id_filter
|
|
152
|
+
|
|
153
|
+
path = "#{FromBuilder.collection_path(@config, @collection)}/#{id_filter[2]}"
|
|
154
|
+
HttpTransport.request(config: @config, method: 'DELETE', path: path)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
data/lib/k9o/envelope.rb
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Standard envelope returned by every k9o surface on success.
|
|
5
|
+
class Envelope
|
|
6
|
+
attr_reader :data, :meta
|
|
7
|
+
|
|
8
|
+
def initialize(data:, meta:)
|
|
9
|
+
@data = data
|
|
10
|
+
@meta = meta
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.from_wire(wire)
|
|
14
|
+
Envelope.new(
|
|
15
|
+
data: wire['data'],
|
|
16
|
+
meta: Meta.from_wire(wire['meta'] || {})
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Meta accessor sugar.
|
|
22
|
+
class Meta
|
|
23
|
+
attr_reader :cursor, :subscription, :idempotency, :warnings, :cost, :timing, :context
|
|
24
|
+
|
|
25
|
+
def initialize(cursor: nil, subscription: nil, idempotency: nil, warnings: [], cost: {}, timing: nil, context: nil)
|
|
26
|
+
@cursor = cursor
|
|
27
|
+
@subscription = subscription
|
|
28
|
+
@idempotency = idempotency
|
|
29
|
+
@warnings = warnings
|
|
30
|
+
@cost = cost
|
|
31
|
+
@timing = timing
|
|
32
|
+
@context = context
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.from_wire(wire)
|
|
36
|
+
cursor = wire['cursor'] ? MetaCursor.new(next_: wire['cursor']['next'], total_count: wire['cursor']['totalCount']) : nil
|
|
37
|
+
sub = wire['subscription'] ? MetaSubscription.new(is_initial: !!wire['subscription']['isInitial']) : nil
|
|
38
|
+
idem = wire['idempotency'] ? MetaIdempotency.new(replayed: !!wire['idempotency']['replayed']) : nil
|
|
39
|
+
warnings = (wire['warnings'] || []).map { |w| MetaWarning.new(code: w['code'] || '', message: w['message'] || '') }
|
|
40
|
+
timing = wire['timing'] ? MetaTiming.new(total_ms: wire['timing']['totalMs'].to_i) : nil
|
|
41
|
+
context = wire['context'] && wire['context']['requestId'] ? MetaContext.new(request_id: wire['context']['requestId']) : nil
|
|
42
|
+
Meta.new(
|
|
43
|
+
cursor: cursor, subscription: sub, idempotency: idem,
|
|
44
|
+
warnings: warnings, cost: wire['cost'] || {},
|
|
45
|
+
timing: timing, context: context
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class MetaCursor
|
|
51
|
+
attr_reader :next, :total_count
|
|
52
|
+
|
|
53
|
+
def initialize(next_:, total_count:)
|
|
54
|
+
@next = next_
|
|
55
|
+
@total_count = total_count
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
MetaSubscription = Struct.new(:is_initial, keyword_init: true)
|
|
60
|
+
MetaIdempotency = Struct.new(:replayed, keyword_init: true)
|
|
61
|
+
MetaWarning = Struct.new(:code, :message, keyword_init: true)
|
|
62
|
+
MetaTiming = Struct.new(:total_ms, keyword_init: true)
|
|
63
|
+
MetaContext = Struct.new(:request_id, keyword_init: true)
|
|
64
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Function lifecycle namespace — k9o.functions.*.
|
|
5
|
+
class Functions
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def deploy(name:, runtime:, code:, tier: nil, config: nil)
|
|
11
|
+
slug = require_project_slug
|
|
12
|
+
body = { 'name' => name, 'runtime' => runtime, 'code' => code }
|
|
13
|
+
body['tier'] = tier if tier
|
|
14
|
+
body['config'] = config if config
|
|
15
|
+
HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'functions'), body: body)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def list(status: nil)
|
|
19
|
+
slug = require_project_slug
|
|
20
|
+
q = status ? { 'status' => status } : nil
|
|
21
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'functions'), query: q)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def get(name)
|
|
25
|
+
slug = require_project_slug
|
|
26
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, "functions/#{enc(name)}"))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def delete(name)
|
|
30
|
+
slug = require_project_slug
|
|
31
|
+
HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.project_path(slug, "functions/#{enc(name)}"))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Invoke a function; payload passed to the runtime verbatim.
|
|
35
|
+
def invoke(name, payload)
|
|
36
|
+
slug = require_project_slug
|
|
37
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
38
|
+
path: UrlGrammar.project_path(slug, "functions/#{enc(name)}/invoke"), body: payload)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def require_project_slug
|
|
44
|
+
Studio.require_project_slug(@config, 'functions')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def enc(s)
|
|
48
|
+
::URI.encode_www_form_component(s)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|