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.
@@ -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
@@ -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
@@ -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
@@ -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