k9o 1.0.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.
Potentially problematic release.
This version of k9o might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +201 -0
- data/NOTICE +4 -0
- data/README.md +67 -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/realtime.rb
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Realtime Subscriptions namespace (ALL-LIVE primitive).
|
|
5
|
+
#
|
|
6
|
+
# Ruby idiom per ch13 §8: #subscribe returns an Enumerator iterated via
|
|
7
|
+
# `.each`. Per ch07 §11.3 + the SDK-abstracted-primitive discipline the
|
|
8
|
+
# SDK picks the transport; SPLIT (polling) is the internal fallback. The
|
|
9
|
+
# Net::HTTP-based Ruby SDK uses SSE primary (no stdlib WebSocket);
|
|
10
|
+
# #subscribe_poll exposes the poll fallback. #replay is one-shot catch-up.
|
|
11
|
+
class Realtime
|
|
12
|
+
def initialize(config)
|
|
13
|
+
@config = config
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Subscribe via SSE transport (primary per §11.3); returns an
|
|
17
|
+
# Enumerator yielding event hashes.
|
|
18
|
+
def subscribe(topic, filter: nil)
|
|
19
|
+
slug = Studio.require_project_slug(@config, 'realtime')
|
|
20
|
+
q = { 'topic' => topic }
|
|
21
|
+
q['filter'] = JSON.dump(filter) if filter
|
|
22
|
+
HttpTransport.stream_sse(config: @config, method: 'GET',
|
|
23
|
+
path: UrlGrammar.project_path(slug, 'realtime/subscribe'), query: q)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# One-shot historical replay per ch07 §11.2 (capped 10k server-side).
|
|
27
|
+
def replay(topic, from_sequence:, filter: nil, until_sequence: nil, limit: nil)
|
|
28
|
+
slug = Studio.require_project_slug(@config, 'realtime')
|
|
29
|
+
unless from_sequence.is_a?(Integer) && from_sequence >= 0
|
|
30
|
+
raise KError.new(
|
|
31
|
+
code: 'K9O_INVALID_FILTER',
|
|
32
|
+
message: 'replay from_sequence must be a non-negative integer',
|
|
33
|
+
context: { fromSequence: from_sequence },
|
|
34
|
+
recommendation: 'Pass the highest sequence number you have already observed (or 0 to replay from the start).'
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
body = { 'topic' => topic, 'fromSequence' => from_sequence }
|
|
38
|
+
body['filter'] = filter if filter
|
|
39
|
+
body['untilSequence'] = until_sequence if until_sequence
|
|
40
|
+
body['limit'] = limit if limit
|
|
41
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
42
|
+
path: UrlGrammar.project_path(slug, 'realtime/replay'), body: body)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Poll-transport fallback per §11.3 (SDK-internal SPLIT). Single batch
|
|
46
|
+
# via replay endpoint; caller loops tracking highest observed sequence.
|
|
47
|
+
def subscribe_poll(topic, from_sequence:, filter: nil)
|
|
48
|
+
replay(topic, from_sequence: from_sequence, filter: filter)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Typed builders for the 6 closed topic families per ch07 §11.1.
|
|
52
|
+
module Topic
|
|
53
|
+
module_function
|
|
54
|
+
|
|
55
|
+
def collection(collection)
|
|
56
|
+
"collections.#{collection}.*"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def workflow_runs
|
|
60
|
+
'studio.workflows.runs.*'
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def query_executed(query_id)
|
|
64
|
+
"studio.queries.executed.#{query_id}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def dashboard_refresh(dashboard_id)
|
|
68
|
+
"studio.dashboards.refresh.#{dashboard_id}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def function_invocations(function_id)
|
|
72
|
+
"forge.functions.invocations.#{function_id}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def audit_org(event_family)
|
|
76
|
+
"audit.org.#{event_family}"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
data/lib/k9o/region.rb
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module K9o
|
|
7
|
+
# Regional host resolution.
|
|
8
|
+
#
|
|
9
|
+
# The region-free org host `<org>.api[-staging].k9o.io` redirects GET/HEAD
|
|
10
|
+
# only and REFUSES a request carrying a body / Authorization / cookie, so
|
|
11
|
+
# credentials must go to `<org>.api[-staging]-<rgn>.k9o.io`. The SDK learns
|
|
12
|
+
# the region with ONE unauthenticated `HEAD https://<org>.api[-staging].k9o.io/`
|
|
13
|
+
# (Net::HTTP never follows redirects, so the 308 is read, not followed),
|
|
14
|
+
# takes the 308 `Location`, and caches the regional origin per client config.
|
|
15
|
+
#
|
|
16
|
+
# - Only a region-free org host is ever probed; any other base URL is used
|
|
17
|
+
# exactly as given.
|
|
18
|
+
# - The probe carries no Authorization, cookie or default header.
|
|
19
|
+
# - The Location is trusted only if https, no userinfo, no port, and host is
|
|
20
|
+
# exactly `<same org>.<same apex family>-<rgn>.k9o.io` (rgn = [a-z]{2}\d{2}).
|
|
21
|
+
# - Any other status or failure keeps the configured base URL. A definite
|
|
22
|
+
# answer is cached; a network failure of the probe is not.
|
|
23
|
+
module Region
|
|
24
|
+
REGION_FREE_ORG_HOST = /\A([a-z][a-z0-9-]{1,62})\.(api|api-staging)\.k9o\.io\z/.freeze
|
|
25
|
+
REGION_CODE = /\A[a-z]{2}\d{2}\z/.freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# The regional origin `location` may name for `host`, or nil. Pure.
|
|
30
|
+
def origin_from_location(host, location, base)
|
|
31
|
+
m = REGION_FREE_ORG_HOST.match(host)
|
|
32
|
+
return nil if m.nil?
|
|
33
|
+
|
|
34
|
+
begin
|
|
35
|
+
u = URI.join(base.sub(%r{/+\z}, '') + '/', location)
|
|
36
|
+
rescue URI::Error, ArgumentError
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
return nil unless u.is_a?(URI::HTTPS) && u.scheme == 'https'
|
|
40
|
+
return nil unless u.userinfo.nil? && u.user.nil? && u.password.nil?
|
|
41
|
+
return nil unless u.port == u.default_port
|
|
42
|
+
|
|
43
|
+
org = Regexp.escape(m[1])
|
|
44
|
+
apex = Regexp.escape(m[2])
|
|
45
|
+
h = u.host.to_s.downcase
|
|
46
|
+
r = /\A#{org}\.#{apex}-([a-z0-9]+)\.k9o\.io\z/.match(h)
|
|
47
|
+
return nil if r.nil? || !REGION_CODE.match?(r[1])
|
|
48
|
+
|
|
49
|
+
"https://#{h}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The origin requests for this client go to: the cached regional origin
|
|
53
|
+
# once learned, else the configured base URL. Never raises.
|
|
54
|
+
def resolve_origin(config)
|
|
55
|
+
config.region_mutex.synchronize do
|
|
56
|
+
cached = config.regional_origin
|
|
57
|
+
return cached unless cached.nil?
|
|
58
|
+
|
|
59
|
+
origin, cacheable = probe(config)
|
|
60
|
+
config.regional_origin = origin if cacheable
|
|
61
|
+
origin
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def probe(config)
|
|
66
|
+
configured = config.base_url
|
|
67
|
+
begin
|
|
68
|
+
uri = URI.parse(configured)
|
|
69
|
+
host = uri.host.to_s.downcase
|
|
70
|
+
rescue URI::Error
|
|
71
|
+
return [configured, true]
|
|
72
|
+
end
|
|
73
|
+
return [configured, true] unless REGION_FREE_ORG_HOST.match?(host)
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
probe_uri = URI.parse("#{configured.sub(%r{/+\z}, '')}/")
|
|
77
|
+
http = config.http_client || ::Net::HTTP.new(probe_uri.host, probe_uri.port)
|
|
78
|
+
http.use_ssl = (probe_uri.scheme == 'https')
|
|
79
|
+
http.read_timeout = 30
|
|
80
|
+
# Deliberately NOT built from default_headers / api_key / org slug.
|
|
81
|
+
req = ::Net::HTTP::Head.new(probe_uri.request_uri)
|
|
82
|
+
req['accept'] = '*/*'
|
|
83
|
+
res = http.request(req)
|
|
84
|
+
if res.code.to_s == '308'
|
|
85
|
+
loc = res['location']
|
|
86
|
+
origin = loc.nil? ? nil : origin_from_location(host, loc.to_s, configured)
|
|
87
|
+
return [origin, true] unless origin.nil?
|
|
88
|
+
end
|
|
89
|
+
[configured, true]
|
|
90
|
+
rescue StandardError
|
|
91
|
+
[configured, false]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
data/lib/k9o/storage.rb
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module K9o
|
|
6
|
+
# `client.storage` buckets and objects (ch04 §9), against
|
|
7
|
+
# `/@<project_slug>/storage/buckets...`.
|
|
8
|
+
#
|
|
9
|
+
# k9o.storage.create_bucket('avatars', public: false)
|
|
10
|
+
# k9o.storage.from('avatars').upload('user/a.jpg', bytes, content_type: 'image/jpeg')
|
|
11
|
+
# k9o.storage.from('avatars').create_signed_url('user/a.jpg', expires_in: 300)
|
|
12
|
+
#
|
|
13
|
+
# Bucket names are logical (the platform resolves the physical store).
|
|
14
|
+
# Signed URLs require an explicit `expires_in` (1..604800, no default).
|
|
15
|
+
# Not offered, by design: signed PUT upload URLs (no gateway route) and copy.
|
|
16
|
+
# Errors pass through unchanged as K9o::KError; object bytes never appear in
|
|
17
|
+
# an SDK-constructed error.
|
|
18
|
+
class Storage
|
|
19
|
+
MAX_EXPIRES_IN = 604_800
|
|
20
|
+
|
|
21
|
+
def initialize(config)
|
|
22
|
+
@config = config
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# GET /@<slug>/storage/buckets
|
|
26
|
+
def list_buckets
|
|
27
|
+
HttpTransport.request(config: @config, method: 'GET', path: buckets_path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# GET .../buckets/<name>
|
|
31
|
+
def get_bucket(name)
|
|
32
|
+
HttpTransport.request(config: @config, method: 'GET', path: bucket_path(name))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# POST .../buckets body { name, ...options }. Options are sent as given:
|
|
36
|
+
# public, fileSizeLimit, allowedMimeTypes, versioning, deleteAfterDays,
|
|
37
|
+
# byosBindingId (snake_case keys are mapped to the wire's camelCase).
|
|
38
|
+
def create_bucket(name, public: nil, file_size_limit: nil, allowed_mime_types: nil, versioning: nil,
|
|
39
|
+
delete_after_days: nil, byos_binding_id: nil)
|
|
40
|
+
body = { 'name' => name }
|
|
41
|
+
body['public'] = public unless public.nil?
|
|
42
|
+
body['fileSizeLimit'] = file_size_limit unless file_size_limit.nil?
|
|
43
|
+
body['allowedMimeTypes'] = allowed_mime_types unless allowed_mime_types.nil?
|
|
44
|
+
body['versioning'] = versioning unless versioning.nil?
|
|
45
|
+
body['deleteAfterDays'] = delete_after_days unless delete_after_days.nil?
|
|
46
|
+
body['byosBindingId'] = byos_binding_id unless byos_binding_id.nil?
|
|
47
|
+
HttpTransport.request(config: @config, method: 'POST', path: buckets_path, body: body)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# PATCH .../buckets/<name>. `patch` is a Hash of only the fields to change
|
|
51
|
+
# (snake_case or wire camelCase keys); an explicit nil clears a limit
|
|
52
|
+
# (fileSizeLimit, allowedMimeTypes, deleteAfterDays, byosBindingId).
|
|
53
|
+
def update_bucket(name, patch)
|
|
54
|
+
body = patch.each_with_object({}) { |(k, v), h| h[wire_key(k)] = v }
|
|
55
|
+
HttpTransport.request(config: @config, method: 'PATCH', path: bucket_path(name), body: body)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# DELETE .../buckets/<name>; `?empty=true` only when empty is true
|
|
59
|
+
# (purges the bucket's objects first).
|
|
60
|
+
def delete_bucket(name, empty: false)
|
|
61
|
+
query = empty == true ? { 'empty' => 'true' } : nil
|
|
62
|
+
HttpTransport.request(config: @config, method: 'DELETE', path: bucket_path(name), query: query)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A handle on one bucket for object operations.
|
|
66
|
+
def from(bucket)
|
|
67
|
+
BucketHandle.new(@config, bucket)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
WIRE_KEYS = {
|
|
71
|
+
'file_size_limit' => 'fileSizeLimit', 'allowed_mime_types' => 'allowedMimeTypes',
|
|
72
|
+
'delete_after_days' => 'deleteAfterDays', 'byos_binding_id' => 'byosBindingId'
|
|
73
|
+
}.freeze
|
|
74
|
+
|
|
75
|
+
def self.buckets_path(config, suffix = '')
|
|
76
|
+
slug = Studio.require_project_slug(config, 'storage')
|
|
77
|
+
UrlGrammar.project_path(slug, "storage/buckets#{suffix}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.bucket_path(config, name, suffix = '')
|
|
81
|
+
invalid('bucket name must not be empty', 'bucket') unless name.is_a?(String) && !name.empty?
|
|
82
|
+
# `.` and `..` survive percent-encoding and are collapsed as dot segments
|
|
83
|
+
# by URL layers, so they would address a different route.
|
|
84
|
+
invalid('bucket name must not be a dot segment', 'bucket', 'dot_segment') if name == '.' || name == '..'
|
|
85
|
+
buckets_path(config, "/#{CGI.escape(name).gsub('+', '%20')}#{suffix}")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.invalid(message, field, reason = 'empty')
|
|
89
|
+
raise KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message,
|
|
90
|
+
context: { field: field, reason: reason })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def wire_key(key)
|
|
96
|
+
k = key.to_s
|
|
97
|
+
WIRE_KEYS.fetch(k, k)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def buckets_path
|
|
101
|
+
Storage.buckets_path(@config)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def bucket_path(name)
|
|
105
|
+
Storage.bucket_path(@config, name)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Object operations on one bucket: k9o.storage.from('avatars').
|
|
109
|
+
class BucketHandle
|
|
110
|
+
def initialize(config, bucket)
|
|
111
|
+
@config = config
|
|
112
|
+
@bucket = bucket
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# POST .../objects (multipart/form-data: file, path, contentType?, upsert, metadata?).
|
|
116
|
+
# `data` is a String (sent as its bytes), or an IO-like responding to #read.
|
|
117
|
+
# `upsert: false` (default) refuses to overwrite an existing path (409).
|
|
118
|
+
def upload(path, data, content_type: nil, upsert: false, metadata: nil)
|
|
119
|
+
require_object_path(path)
|
|
120
|
+
bytes = data.respond_to?(:read) ? data.read : data
|
|
121
|
+
unless bytes.is_a?(String)
|
|
122
|
+
Storage.invalid('upload data must be a String or an IO', 'data', 'type')
|
|
123
|
+
end
|
|
124
|
+
bytes = bytes.encode(Encoding::UTF_8) unless [Encoding::BINARY, Encoding::UTF_8].include?(bytes.encoding)
|
|
125
|
+
file = { name: 'file', value: bytes, filename: path.split('/').last.to_s.then { |f| f.empty? ? 'file' : f } }
|
|
126
|
+
file[:content_type] = content_type || 'application/octet-stream'
|
|
127
|
+
parts = [file, { name: 'path', value: path }]
|
|
128
|
+
parts << { name: 'contentType', value: content_type } unless content_type.nil?
|
|
129
|
+
parts << { name: 'upsert', value: upsert == true ? 'true' : 'false' }
|
|
130
|
+
parts << { name: 'metadata', value: JSON.dump(metadata) } unless metadata.nil?
|
|
131
|
+
HttpTransport.request(config: @config, method: 'POST', path: objects_path, multipart: parts)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# POST .../objects/sign { path, expiresIn, download? }. `expires_in` is
|
|
135
|
+
# required: an Integer from 1 to 604800 seconds; refused before any request.
|
|
136
|
+
def create_signed_url(path, expires_in:, download: nil)
|
|
137
|
+
require_object_path(path)
|
|
138
|
+
unless expires_in.is_a?(Integer) && expires_in >= 1 && expires_in <= MAX_EXPIRES_IN
|
|
139
|
+
raise KError.new(
|
|
140
|
+
code: 'K9O_INVALID_FIELD_VALUE',
|
|
141
|
+
message: "expires_in is required and must be an integer from 1 to #{MAX_EXPIRES_IN} seconds",
|
|
142
|
+
context: { field: 'expiresIn', reason: 'out_of_range' },
|
|
143
|
+
recommendation: 'Pass an explicit expires_in; signed URLs have no default lifetime.'
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
body = { 'path' => path, 'expiresIn' => expires_in }
|
|
147
|
+
body['download'] = download unless download.nil?
|
|
148
|
+
HttpTransport.request(config: @config, method: 'POST', path: objects_path('/sign'), body: body)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# GET .../objects, with ?prefix= only when given.
|
|
152
|
+
def list(prefix: nil)
|
|
153
|
+
query = prefix.nil? ? nil : { 'prefix' => prefix }
|
|
154
|
+
HttpTransport.request(config: @config, method: 'GET', path: objects_path, query: query)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# DELETE .../objects?path=
|
|
158
|
+
def remove(path)
|
|
159
|
+
require_object_path(path)
|
|
160
|
+
HttpTransport.request(config: @config, method: 'DELETE', path: objects_path, query: { 'path' => path })
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def objects_path(suffix = '')
|
|
166
|
+
Storage.bucket_path(@config, @bucket, "/objects#{suffix}")
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def require_object_path(path)
|
|
170
|
+
return if path.is_a?(String) && !path.empty?
|
|
171
|
+
|
|
172
|
+
Storage.invalid('object path must not be empty', 'path')
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
data/lib/k9o/studio.rb
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Studio namespace (workflows + dashboards).
|
|
5
|
+
# Ruby idiom per ch13 §2.1.8: snake_case + raise K9o::KError + Envelope return.
|
|
6
|
+
#
|
|
7
|
+
# k9o.studio.workflows.list(status: 'active')
|
|
8
|
+
# k9o.studio.dashboards.pin('dsh_x')
|
|
9
|
+
class Studio
|
|
10
|
+
attr_reader :workflows, :dashboards
|
|
11
|
+
|
|
12
|
+
def initialize(config)
|
|
13
|
+
@workflows = Workflows.new(config)
|
|
14
|
+
@dashboards = Dashboards.new(config)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.require_project_slug(config, surface)
|
|
18
|
+
slug = config.project_slug
|
|
19
|
+
if slug.nil? || slug.empty?
|
|
20
|
+
raise KError.new(
|
|
21
|
+
code: 'K9O_MISSING_PROJECT_CONTEXT',
|
|
22
|
+
message: "#{surface} operations require project_slug bound on the client"
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
slug
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# k9o.studio.workflows.* per ch07 §9.
|
|
29
|
+
class Workflows
|
|
30
|
+
attr_reader :runs
|
|
31
|
+
|
|
32
|
+
def initialize(config)
|
|
33
|
+
@config = config
|
|
34
|
+
@runs = Runs.new(config)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def list(status: nil, trigger_type: nil, limit: nil, cursor: nil)
|
|
38
|
+
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
39
|
+
q = {}
|
|
40
|
+
q['status'] = status if status
|
|
41
|
+
q['triggerType'] = trigger_type if trigger_type
|
|
42
|
+
q['limit'] = limit if limit
|
|
43
|
+
q['cursor'] = cursor if cursor
|
|
44
|
+
HttpTransport.request(config: @config, method: 'GET',
|
|
45
|
+
path: UrlGrammar.project_path(slug, 'workflows'), query: q)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create(workflow)
|
|
49
|
+
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
50
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
51
|
+
path: UrlGrammar.project_path(slug, 'workflows'), body: workflow)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read(workflow_id)
|
|
55
|
+
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
56
|
+
HttpTransport.request(config: @config, method: 'GET',
|
|
57
|
+
path: UrlGrammar.project_path(slug, "workflows/#{encode(workflow_id)}"))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update(workflow_id, updates)
|
|
61
|
+
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
62
|
+
HttpTransport.request(config: @config, method: 'PATCH',
|
|
63
|
+
path: UrlGrammar.project_path(slug, "workflows/#{encode(workflow_id)}"), body: updates)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def delete(workflow_id)
|
|
67
|
+
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
68
|
+
HttpTransport.request(config: @config, method: 'DELETE',
|
|
69
|
+
path: UrlGrammar.project_path(slug, "workflows/#{encode(workflow_id)}"))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Manual-trigger a run (trigger_type=manual only) per ch07 §9.6.
|
|
73
|
+
def trigger(workflow_id, trigger_context = {})
|
|
74
|
+
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
75
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
76
|
+
path: UrlGrammar.project_path(slug, "workflows/#{encode(workflow_id)}/runs"),
|
|
77
|
+
body: trigger_context)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def encode(s)
|
|
83
|
+
::URI.encode_www_form_component(s)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# k9o.studio.workflows.runs.* per ch07 §9.6.
|
|
87
|
+
class Runs
|
|
88
|
+
def initialize(config)
|
|
89
|
+
@config = config
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def list(workflow_id, status: nil)
|
|
93
|
+
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
94
|
+
q = status ? { 'status' => status } : nil
|
|
95
|
+
HttpTransport.request(config: @config, method: 'GET',
|
|
96
|
+
path: UrlGrammar.project_path(slug, "workflows/#{enc(workflow_id)}/runs"), query: q)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def read(workflow_id, run_id)
|
|
100
|
+
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
101
|
+
HttpTransport.request(config: @config, method: 'GET',
|
|
102
|
+
path: UrlGrammar.project_path(slug, "workflows/#{enc(workflow_id)}/runs/#{enc(run_id)}"))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def cancel(workflow_id, run_id)
|
|
106
|
+
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
107
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
108
|
+
path: UrlGrammar.project_path(slug, "workflows/#{enc(workflow_id)}/runs/#{enc(run_id)}/cancel"))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def enc(s)
|
|
114
|
+
::URI.encode_www_form_component(s)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# k9o.studio.dashboards.* per ch04 §3.10.
|
|
120
|
+
class Dashboards
|
|
121
|
+
def initialize(config)
|
|
122
|
+
@config = config
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def list(status: nil)
|
|
126
|
+
slug = Studio.require_project_slug(@config, 'studio.dashboards')
|
|
127
|
+
q = status ? { 'status' => status } : nil
|
|
128
|
+
HttpTransport.request(config: @config, method: 'GET',
|
|
129
|
+
path: UrlGrammar.project_path(slug, 'dashboards'), query: q)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def create(dashboard)
|
|
133
|
+
slug = Studio.require_project_slug(@config, 'studio.dashboards')
|
|
134
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
135
|
+
path: UrlGrammar.project_path(slug, 'dashboards'), body: dashboard)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def read(dashboard_id)
|
|
139
|
+
slug = Studio.require_project_slug(@config, 'studio.dashboards')
|
|
140
|
+
HttpTransport.request(config: @config, method: 'GET',
|
|
141
|
+
path: UrlGrammar.project_path(slug, "dashboards/#{enc(dashboard_id)}"))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def update(dashboard_id, updates)
|
|
145
|
+
slug = Studio.require_project_slug(@config, 'studio.dashboards')
|
|
146
|
+
HttpTransport.request(config: @config, method: 'PATCH',
|
|
147
|
+
path: UrlGrammar.project_path(slug, "dashboards/#{enc(dashboard_id)}"), body: updates)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def delete(dashboard_id)
|
|
151
|
+
slug = Studio.require_project_slug(@config, 'studio.dashboards')
|
|
152
|
+
HttpTransport.request(config: @config, method: 'DELETE',
|
|
153
|
+
path: UrlGrammar.project_path(slug, "dashboards/#{enc(dashboard_id)}"))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def pin(dashboard_id)
|
|
157
|
+
slug = Studio.require_project_slug(@config, 'studio.dashboards')
|
|
158
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
159
|
+
path: UrlGrammar.project_path(slug, "dashboards/#{enc(dashboard_id)}/pin"))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def unpin(dashboard_id)
|
|
163
|
+
slug = Studio.require_project_slug(@config, 'studio.dashboards')
|
|
164
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
165
|
+
path: UrlGrammar.project_path(slug, "dashboards/#{enc(dashboard_id)}/unpin"))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def enc(s)
|
|
171
|
+
::URI.encode_www_form_component(s)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module K9o
|
|
6
|
+
# URL grammar v2.5.68 helpers.
|
|
7
|
+
module UrlGrammar
|
|
8
|
+
SLUG_RE = /\A[a-z][a-z0-9-]{1,62}\z/
|
|
9
|
+
|
|
10
|
+
# Bare platform apex hosts org-subdomain derivation applies to —
|
|
11
|
+
# production and staging share the <org_slug>.<apex> wire-routing
|
|
12
|
+
# scheme (confirmed live). A third apex needs the same confirmation
|
|
13
|
+
# before joining this set.
|
|
14
|
+
ORG_DERIVABLE_APEXES = %w[api.k9o.io api-staging.k9o.io].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def validate_base_url(base_url)
|
|
19
|
+
raise KError.new(
|
|
20
|
+
code: 'K9O_INVALID_BASE_URL',
|
|
21
|
+
message: "base_url #{base_url.inspect} is not a valid URL",
|
|
22
|
+
context: { base_url: base_url }
|
|
23
|
+
) unless base_url.is_a?(String) && !base_url.empty?
|
|
24
|
+
|
|
25
|
+
begin
|
|
26
|
+
uri = URI.parse(base_url)
|
|
27
|
+
rescue URI::InvalidURIError => e
|
|
28
|
+
raise KError.new(
|
|
29
|
+
code: 'K9O_INVALID_BASE_URL',
|
|
30
|
+
message: "base_url #{base_url.inspect} is not a valid URL: #{e.message}",
|
|
31
|
+
context: { base_url: base_url }
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
unless %w[http https].include?(uri.scheme)
|
|
36
|
+
raise KError.new(
|
|
37
|
+
code: 'K9O_INVALID_BASE_URL',
|
|
38
|
+
message: "base_url #{base_url.inspect} uses unsupported scheme #{uri.scheme.inspect}; expected http or https",
|
|
39
|
+
context: { base_url: base_url, scheme: uri.scheme }
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
if uri.host.nil? || uri.host.empty?
|
|
43
|
+
raise KError.new(
|
|
44
|
+
code: 'K9O_INVALID_BASE_URL',
|
|
45
|
+
message: "base_url #{base_url.inspect} missing host",
|
|
46
|
+
context: { base_url: base_url }
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
if uri.path && uri.path != '' && uri.path != '/'
|
|
50
|
+
raise KError.new(
|
|
51
|
+
code: 'K9O_INVALID_BASE_URL',
|
|
52
|
+
message: "base_url #{base_url.inspect} must not contain a path segment; got #{uri.path.inspect}",
|
|
53
|
+
context: { base_url: base_url, path: uri.path }
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
if uri.query || uri.fragment
|
|
57
|
+
raise KError.new(
|
|
58
|
+
code: 'K9O_INVALID_BASE_URL',
|
|
59
|
+
message: "base_url #{base_url.inspect} must not contain query or fragment",
|
|
60
|
+
context: { base_url: base_url }
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
host_port = uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
|
|
65
|
+
"#{uri.scheme}://#{host_port}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Resolve the effective base_url. base_url is
|
|
69
|
+
# OPTIONAL — it defaults to the platform apex https://api.k9o.io. When the
|
|
70
|
+
# (validated) host is a bare apex in ORG_DERIVABLE_APEXES and org_slug is
|
|
71
|
+
# supplied, the SDK composes the canonical org-subdomain wire root
|
|
72
|
+
# https://<org_slug>.<apex> (ch01 §5.4). An explicit <org_slug> subdomain,
|
|
73
|
+
# a self-hosted / Sovereign host, or a dev host (anything not in that
|
|
74
|
+
# set) passes through unchanged — org_slug is intentionally NOT composed
|
|
75
|
+
# onto a non-apex host.
|
|
76
|
+
#
|
|
77
|
+
# Fixed 2026-08-30 (SDK-2): this used to silently ignore org_slug for
|
|
78
|
+
# api-staging.k9o.io too, not just genuine self-hosted/custom hosts,
|
|
79
|
+
# since only the literal production apex string was checked.
|
|
80
|
+
def resolve_base_url(base_url, org_slug = nil)
|
|
81
|
+
validated = validate_base_url(base_url.nil? ? 'https://api.k9o.io' : base_url)
|
|
82
|
+
uri = URI.parse(validated)
|
|
83
|
+
if ORG_DERIVABLE_APEXES.include?(uri.host) && org_slug
|
|
84
|
+
validate_slug(org_slug, 'org')
|
|
85
|
+
return "#{uri.scheme}://#{org_slug}.#{uri.host}"
|
|
86
|
+
end
|
|
87
|
+
validated
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_slug(slug, kind)
|
|
91
|
+
unless slug.is_a?(String) && slug.match?(SLUG_RE) && !slug.end_with?('-')
|
|
92
|
+
raise KError.new(
|
|
93
|
+
code: 'K9O_INVALID_FIELD_VALUE',
|
|
94
|
+
message: "#{kind}_slug #{slug.inspect} does not match required format ^[a-z][a-z0-9-]{1,62}$ (must not end with hyphen)",
|
|
95
|
+
context: { slug: slug, kind: kind }
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def project_path(project_slug, sub_resource)
|
|
101
|
+
cleaned = sub_resource.sub(%r{\A/}, '')
|
|
102
|
+
"/@#{project_slug}/#{cleaned}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def org_path(resource)
|
|
106
|
+
"/#{resource.sub(%r{\A/}, '')}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def platform_path(resource)
|
|
110
|
+
"/#{resource.sub(%r{\A/}, '')}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|