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/policies.rb
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Policy bundle lifecycle namespace — k9o.policies.*.
|
|
5
|
+
class Policies
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def current
|
|
11
|
+
slug = require_project_slug
|
|
12
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'policy'))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def list(status: nil)
|
|
16
|
+
slug = require_project_slug
|
|
17
|
+
q = status ? { 'status' => status } : nil
|
|
18
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'policies'), query: q)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create(name:, rules:)
|
|
22
|
+
slug = require_project_slug
|
|
23
|
+
HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'policies'),
|
|
24
|
+
body: { 'name' => name, 'rules' => rules })
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def validate(rules)
|
|
28
|
+
slug = require_project_slug
|
|
29
|
+
HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'policies/validate'),
|
|
30
|
+
body: { 'rules' => rules })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(bundle_id)
|
|
34
|
+
slug = require_project_slug
|
|
35
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def update(bundle_id, patch)
|
|
39
|
+
slug = require_project_slug
|
|
40
|
+
HttpTransport.request(config: @config, method: 'PATCH',
|
|
41
|
+
path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"), body: patch)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete(bundle_id)
|
|
45
|
+
slug = require_project_slug
|
|
46
|
+
HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def publish(bundle_id)
|
|
50
|
+
slug = require_project_slug
|
|
51
|
+
HttpTransport.request(config: @config, method: 'POST',
|
|
52
|
+
path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}/publish"))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def require_project_slug
|
|
58
|
+
Studio.require_project_slug(@config, 'policies')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def enc(s)
|
|
62
|
+
::URI.encode_www_form_component(s)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/k9o/projects.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Project lifecycle namespace — k9o.projects.* (Ruby snake_case).
|
|
5
|
+
class Projects
|
|
6
|
+
def initialize(config)
|
|
7
|
+
@config = config
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def list
|
|
11
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('projects'))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create(slug:, name:, custody_mode: nil)
|
|
15
|
+
UrlGrammar.validate_slug(slug, 'project')
|
|
16
|
+
body = { 'slug' => slug, 'name' => name }
|
|
17
|
+
body['custodyMode'] = custody_mode if custody_mode
|
|
18
|
+
HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('projects'), body: body)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def get(slug)
|
|
22
|
+
UrlGrammar.validate_slug(slug, 'project')
|
|
23
|
+
HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'project'))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def update(slug, name: nil)
|
|
27
|
+
UrlGrammar.validate_slug(slug, 'project')
|
|
28
|
+
patch = {}
|
|
29
|
+
patch['name'] = name if name
|
|
30
|
+
HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.project_path(slug, 'project'), body: patch)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
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: #subscribe returns an Enumerator iterated via
|
|
7
|
+
# `.each`. Per + 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); 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 (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 (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.
|
|
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, 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: 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.*.
|
|
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).
|
|
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.*.
|
|
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.*.
|
|
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
|