k9o 1.2.0 → 1.4.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 +4 -4
- data/CHANGELOG.md +55 -1
- data/README.md +359 -68
- data/lib/k9o/ai.rb +15 -0
- data/lib/k9o/approvals.rb +159 -0
- data/lib/k9o/audit.rb +12 -14
- data/lib/k9o/client.rb +11 -7
- data/lib/k9o/compute.rb +32 -15
- data/lib/k9o/data.rb +67 -1
- data/lib/k9o/functions.rb +48 -9
- data/lib/k9o/gate.rb +9 -18
- data/lib/k9o/http_transport.rb +10 -5
- data/lib/k9o/kibo.rb +3 -3
- data/lib/k9o/me.rb +27 -87
- data/lib/k9o/org.rb +81 -68
- data/lib/k9o/orgs.rb +1 -0
- data/lib/k9o/policies.rb +25 -6
- data/lib/k9o/projects.rb +43 -8
- data/lib/k9o/query.rb +174 -0
- data/lib/k9o/realtime.rb +13 -35
- data/lib/k9o/region.rb +20 -18
- data/lib/k9o/schema.rb +35 -0
- data/lib/k9o/streams.rb +94 -0
- data/lib/k9o/studio.rb +21 -11
- data/lib/k9o/url_grammar.rb +46 -20
- data/lib/k9o/vault.rb +76 -80
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +4 -0
- data/sig/k9o/ai.rbs +4 -0
- data/sig/k9o/approvals.rbs +49 -0
- data/sig/k9o/gate.rbs +7 -6
- data/sig/k9o/query.rbs +26 -0
- data/sig/k9o/region.rbs +14 -3
- metadata +8 -2
data/lib/k9o/query.rb
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# `client.query(sql, params)` and `client.search(collection, column:, q:)`.
|
|
5
|
+
#
|
|
6
|
+
# Wire (project-plane):
|
|
7
|
+
# POST /@<slug>/query body { sql:, params? } -> { data: [row, ...], meta }
|
|
8
|
+
# GET /@<slug>/data/<collection>?textSearch=<column>:<q>... (the select builder's +text_search+)
|
|
9
|
+
#
|
|
10
|
+
# +params+ is an Array (positional, bound to $1, $2, ...; the SQL is sent as written) or a Hash of named values
|
|
11
|
+
# (+:name+ placeholders are rewritten to $n here and the values become the positional list). Values are bound by
|
|
12
|
+
# the database, never interpolated into the SQL text.
|
|
13
|
+
module Query
|
|
14
|
+
IDENT_START = /[A-Za-z_]/.freeze
|
|
15
|
+
IDENT_CHAR = /[A-Za-z0-9_]/.freeze
|
|
16
|
+
DOLLAR_TAG = /\A\$([A-Za-z_][A-Za-z0-9_]*)?\$/.freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def invalid(message, context = {})
|
|
21
|
+
KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: context)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def check_value(value, where)
|
|
25
|
+
return value if value.nil? || value == true || value == false || value.is_a?(String)
|
|
26
|
+
return value if value.is_a?(Integer)
|
|
27
|
+
return value if value.is_a?(Float) && value.finite?
|
|
28
|
+
|
|
29
|
+
raise invalid("#{where} must be a string, number, boolean or nil; serialise anything else (for example as JSON text) first")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def lookup(named, name)
|
|
33
|
+
return [true, named[name]] if named.key?(name)
|
|
34
|
+
return [true, named[name.to_sym]] if named.key?(name.to_sym)
|
|
35
|
+
|
|
36
|
+
[false, nil]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Rewrite +:name+ placeholders to $n. Skips quoted text, comments, dollar-quoted strings and +::+ casts.
|
|
40
|
+
# Returns [sql, params].
|
|
41
|
+
def translate_named_params(sql, named)
|
|
42
|
+
order = []
|
|
43
|
+
out = +''
|
|
44
|
+
i = 0
|
|
45
|
+
n = sql.length
|
|
46
|
+
while i < n
|
|
47
|
+
c = sql[i]
|
|
48
|
+
nxt = i + 1 < n ? sql[i + 1] : ''
|
|
49
|
+
if c == "'" || ((c == 'E' || c == 'e') && nxt == "'" && !(i > 0 && sql[i - 1] =~ IDENT_CHAR))
|
|
50
|
+
escapes = c != "'"
|
|
51
|
+
j = escapes ? i + 2 : i + 1
|
|
52
|
+
while j < n
|
|
53
|
+
if escapes && sql[j] == '\\'
|
|
54
|
+
j += 2
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
if sql[j] == "'"
|
|
58
|
+
if sql[j + 1] == "'"
|
|
59
|
+
j += 2
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
j += 1
|
|
65
|
+
end
|
|
66
|
+
out << sql[i...[j + 1, n].min]
|
|
67
|
+
i = j + 1
|
|
68
|
+
elsif c == '"'
|
|
69
|
+
j = i + 1
|
|
70
|
+
while j < n
|
|
71
|
+
if sql[j] == '"'
|
|
72
|
+
if sql[j + 1] == '"'
|
|
73
|
+
j += 2
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
break
|
|
77
|
+
end
|
|
78
|
+
j += 1
|
|
79
|
+
end
|
|
80
|
+
out << sql[i...[j + 1, n].min]
|
|
81
|
+
i = j + 1
|
|
82
|
+
elsif c == '-' && nxt == '-'
|
|
83
|
+
j = sql.index("\n", i) || n
|
|
84
|
+
out << sql[i...j]
|
|
85
|
+
i = j
|
|
86
|
+
elsif c == '/' && nxt == '*'
|
|
87
|
+
depth = 1
|
|
88
|
+
j = i + 2
|
|
89
|
+
while j < n && depth > 0
|
|
90
|
+
if sql[j, 2] == '/*'
|
|
91
|
+
depth += 1
|
|
92
|
+
j += 2
|
|
93
|
+
elsif sql[j, 2] == '*/'
|
|
94
|
+
depth -= 1
|
|
95
|
+
j += 2
|
|
96
|
+
else
|
|
97
|
+
j += 1
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
out << sql[i...j]
|
|
101
|
+
i = j
|
|
102
|
+
elsif c == '$'
|
|
103
|
+
m = DOLLAR_TAG.match(sql[i..-1])
|
|
104
|
+
if m
|
|
105
|
+
close = sql.index(m[0], i + m[0].length)
|
|
106
|
+
stop = close.nil? ? n : close + m[0].length
|
|
107
|
+
out << sql[i...stop]
|
|
108
|
+
i = stop
|
|
109
|
+
else
|
|
110
|
+
out << c
|
|
111
|
+
i += 1
|
|
112
|
+
end
|
|
113
|
+
elsif c == ':' && nxt == ':'
|
|
114
|
+
out << '::'
|
|
115
|
+
i += 2
|
|
116
|
+
elsif c == ':' && nxt != '' && nxt =~ IDENT_START && !(i > 0 && sql[i - 1] =~ IDENT_CHAR)
|
|
117
|
+
j = i + 2
|
|
118
|
+
j += 1 while j < n && sql[j] =~ IDENT_CHAR
|
|
119
|
+
name = sql[(i + 1)...j]
|
|
120
|
+
unless order.include?(name)
|
|
121
|
+
found, = lookup(named, name)
|
|
122
|
+
raise invalid("no value for :#{name}", name: name) unless found
|
|
123
|
+
|
|
124
|
+
order << name
|
|
125
|
+
end
|
|
126
|
+
out << "$#{order.index(name) + 1}"
|
|
127
|
+
i = j
|
|
128
|
+
else
|
|
129
|
+
out << c
|
|
130
|
+
i += 1
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
values = order.map { |name| check_value(lookup(named, name)[1], "the value for :#{name}") }
|
|
134
|
+
[out, values]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Builds [path, body] for POST /@<slug>/query.
|
|
138
|
+
def prepare(config, sql, params)
|
|
139
|
+
slug = Studio.require_project_slug(config, 'query')
|
|
140
|
+
text = sql
|
|
141
|
+
values = []
|
|
142
|
+
if params.is_a?(Hash)
|
|
143
|
+
text, values = translate_named_params(sql, params)
|
|
144
|
+
elsif !params.nil?
|
|
145
|
+
values = params.each_with_index.map { |v, i| check_value(v, "params[#{i}]") }
|
|
146
|
+
end
|
|
147
|
+
body = { 'sql' => text }
|
|
148
|
+
body['params'] = values unless values.empty?
|
|
149
|
+
[UrlGrammar.project_path(slug, 'query'), body]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Client-root methods mixed into K9oClient.
|
|
154
|
+
module QueryMethods
|
|
155
|
+
# POST /@<slug>/query — run one read-only SQL statement; +data+ is the array of rows.
|
|
156
|
+
# An Array binds positionally to $1, $2, ...; a Hash binds +:name+ placeholders.
|
|
157
|
+
def query(sql, params = nil)
|
|
158
|
+
path, body = Query.prepare(@config, sql, params)
|
|
159
|
+
HttpTransport.request(config: @config, method: 'POST', path: path, body: body)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Full-text search over one text column of a collection, best matches first. Sugar over
|
|
163
|
+
# +from(collection).select.text_search(column, q, ...)+; +limit+ and +cursor+ page the result.
|
|
164
|
+
def search(collection, column:, q:, language: nil, type: nil, limit: nil, cursor: nil)
|
|
165
|
+
opts = {}
|
|
166
|
+
opts[:language] = language unless language.nil?
|
|
167
|
+
opts[:type] = type unless type.nil?
|
|
168
|
+
builder = from(collection).select.text_search(column, q, **opts)
|
|
169
|
+
builder.limit(limit) unless limit.nil?
|
|
170
|
+
builder.cursor(cursor) unless cursor.nil?
|
|
171
|
+
builder.execute
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
data/lib/k9o/realtime.rb
CHANGED
|
@@ -1,54 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module K9o
|
|
4
|
-
# Realtime Subscriptions namespace
|
|
4
|
+
# Realtime Subscriptions namespace.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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.
|
|
6
|
+
# Transport: SSE over GET /@<slug>/realtime/subscribe. The gateway serves no WebSocket upgrade and no durable
|
|
7
|
+
# replay, so neither is offered; after an `event: resync` frame the caller re-reads current state from the data
|
|
8
|
+
# plane and resubscribes. #subscribe returns an Enumerator iterated via `.each`.
|
|
11
9
|
class Realtime
|
|
12
10
|
def initialize(config)
|
|
13
11
|
@config = config
|
|
14
12
|
end
|
|
15
13
|
|
|
16
|
-
# Subscribe via SSE
|
|
17
|
-
#
|
|
14
|
+
# Subscribe via SSE; returns an Enumerator yielding event hashes
|
|
15
|
+
# { 'topic', 'eventType', 'payload', 'occurredAt' } (the gateway's spelling). Control frames such as `resync`
|
|
16
|
+
# carry no +eventType+ and are skipped.
|
|
18
17
|
def subscribe(topic, filter: nil)
|
|
19
18
|
slug = Studio.require_project_slug(@config, 'realtime')
|
|
20
19
|
q = { 'topic' => topic }
|
|
21
20
|
q['filter'] = JSON.dump(filter) if filter
|
|
22
|
-
HttpTransport.stream_sse(config: @config, method: 'GET',
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
)
|
|
21
|
+
frames = HttpTransport.stream_sse(config: @config, method: 'GET',
|
|
22
|
+
path: UrlGrammar.project_path(slug, 'realtime/subscribe'), query: q)
|
|
23
|
+
Enumerator.new do |yielder|
|
|
24
|
+
frames.each do |event|
|
|
25
|
+
yielder << event if event.is_a?(Hash) && event['eventType'].is_a?(String)
|
|
26
|
+
end
|
|
36
27
|
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
28
|
end
|
|
50
29
|
|
|
51
|
-
# Typed builders for the 6 closed topic families.
|
|
52
30
|
module Topic
|
|
53
31
|
module_function
|
|
54
32
|
|
data/lib/k9o/region.rb
CHANGED
|
@@ -6,30 +6,32 @@ require 'uri'
|
|
|
6
6
|
module K9o
|
|
7
7
|
# Regional host resolution.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
# only and REFUSES a request carrying a body /
|
|
11
|
-
# credentials must go to `<org>.api
|
|
12
|
-
# the region with ONE unauthenticated
|
|
13
|
-
# (Net::HTTP never follows redirects, so the 308 is read, not followed),
|
|
9
|
+
# A region-free org host `<org>.api.<rest>` (or `<org>.api-staging.<rest>`)
|
|
10
|
+
# redirects GET/HEAD only and REFUSES a request carrying a body /
|
|
11
|
+
# Authorization / cookie, so credentials must go to `<org>.api-<rgn>.<rest>`.
|
|
12
|
+
# The SDK learns the region with ONE unauthenticated HEAD to the configured
|
|
13
|
+
# host (Net::HTTP never follows redirects, so the 308 is read, not followed),
|
|
14
14
|
# takes the 308 `Location`, and caches the regional origin per client config.
|
|
15
15
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
16
|
+
# Nothing here knows an apex: the host comes from the configured base URL and
|
|
17
|
+
# `<rest>` is whatever follows the gateway label, so public-cloud,
|
|
18
|
+
# internal-apex, air-gapped and localhost deployments all work.
|
|
19
|
+
#
|
|
20
|
+
# - Only a region-free org host (see UrlGrammar.parse_api_host) is ever
|
|
21
|
+
# probed; any other base URL is used exactly as given.
|
|
18
22
|
# - The probe carries no Authorization, cookie or default header.
|
|
19
23
|
# - The Location is trusted only if https, no userinfo, no port, and host is
|
|
20
|
-
# exactly
|
|
24
|
+
# exactly the same org and the same remainder with a region code
|
|
25
|
+
# ([a-z]{2}\d{2}) appended to the gateway label.
|
|
21
26
|
# - Any other status or failure keeps the configured base URL. A definite
|
|
22
27
|
# answer is cached; a network failure of the probe is not.
|
|
23
28
|
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
29
|
module_function
|
|
28
30
|
|
|
29
31
|
# The regional origin `location` may name for `host`, or nil. Pure.
|
|
30
32
|
def origin_from_location(host, location, base)
|
|
31
|
-
|
|
32
|
-
return nil if
|
|
33
|
+
from = UrlGrammar.parse_api_host(host)
|
|
34
|
+
return nil if from.nil? || from.org_slug.nil? || !from.region.nil?
|
|
33
35
|
|
|
34
36
|
begin
|
|
35
37
|
u = URI.join(base.sub(%r{/+\z}, '') + '/', location)
|
|
@@ -40,11 +42,10 @@ module K9o
|
|
|
40
42
|
return nil unless u.userinfo.nil? && u.user.nil? && u.password.nil?
|
|
41
43
|
return nil unless u.port == u.default_port
|
|
42
44
|
|
|
43
|
-
org = Regexp.escape(m[1])
|
|
44
|
-
apex = Regexp.escape(m[2])
|
|
45
45
|
h = u.host.to_s.downcase
|
|
46
|
-
|
|
47
|
-
return nil if
|
|
46
|
+
to = UrlGrammar.parse_api_host(h)
|
|
47
|
+
return nil if to.nil? || to.region.nil?
|
|
48
|
+
return nil unless to.org_slug == from.org_slug && to.family == from.family && to.apex == from.apex
|
|
48
49
|
|
|
49
50
|
"https://#{h}"
|
|
50
51
|
end
|
|
@@ -70,7 +71,8 @@ module K9o
|
|
|
70
71
|
rescue URI::Error
|
|
71
72
|
return [configured, true]
|
|
72
73
|
end
|
|
73
|
-
|
|
74
|
+
parsed_host = UrlGrammar.parse_api_host(host)
|
|
75
|
+
return [configured, true] if parsed_host.nil? || parsed_host.org_slug.nil? || !parsed_host.region.nil?
|
|
74
76
|
|
|
75
77
|
begin
|
|
76
78
|
probe_uri = URI.parse("#{configured.sub(%r{/+\z}, '')}/")
|
data/lib/k9o/schema.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Schema: k9o.schema.*
|
|
5
|
+
#
|
|
6
|
+
# collections.list(limit:, cursor:) GET /@<slug>/schema/collections
|
|
7
|
+
#
|
|
8
|
+
# The list is cursor-paginated and ordered by collection name: pass +meta.cursor.next+ of one page as +cursor+ of
|
|
9
|
+
# the next, until it is nil.
|
|
10
|
+
class Schema
|
|
11
|
+
attr_reader :collections
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@collections = SchemaCollections.new(config)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class SchemaCollections
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The project's collections by name, one page at a time. +limit+ is 1 to 200 (default 50).
|
|
24
|
+
def list(limit: nil, cursor: nil)
|
|
25
|
+
route = UrlGrammar.project_path(Studio.require_project_slug(@config, 'schema'), 'schema/collections')
|
|
26
|
+
ApprovalChecks.check_limit(limit)
|
|
27
|
+
raise ApprovalChecks.invalid('cursor must be a string', cursor: cursor) unless cursor.nil? || cursor.is_a?(String)
|
|
28
|
+
|
|
29
|
+
query = {}
|
|
30
|
+
query['limit'] = limit unless limit.nil?
|
|
31
|
+
query['cursor'] = cursor unless cursor.nil?
|
|
32
|
+
HttpTransport.request(config: @config, method: 'GET', path: route, query: query)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/k9o/streams.rb
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# User data streams namespace: k9o.streams.*.
|
|
5
|
+
#
|
|
6
|
+
# Routes under /@<slug>/data-streams/user. Publish needs project admin; subscribe and replay need admin or
|
|
7
|
+
# auditor. While the stream data plane is unavailable the gateway answers K9O_STREAM_UNAVAILABLE (503); a bad
|
|
8
|
+
# subject answers K9O_STREAM_INVALID. Both raise K9o::KError.
|
|
9
|
+
class Streams
|
|
10
|
+
@reconnect_delay = 0.25
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Seconds to wait before reconnecting after the server closes a subscribe connection.
|
|
14
|
+
attr_accessor :reconnect_delay
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(config)
|
|
18
|
+
@config = config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# POST /@<slug>/data-streams/user — +max_bytes+ is the storage ceiling; the slug is derived from +display_name+.
|
|
22
|
+
def create(display_name, max_bytes, retention_seconds: nil)
|
|
23
|
+
body = { 'displayName' => display_name, 'maxBytes' => max_bytes }
|
|
24
|
+
body['retentionSeconds'] = retention_seconds unless retention_seconds.nil?
|
|
25
|
+
call('POST', path, body)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# GET /@<slug>/data-streams/user — the project's active user streams.
|
|
29
|
+
def list
|
|
30
|
+
call('GET', path)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# GET /@<slug>/data-streams/user/<slug>
|
|
34
|
+
def get(slug)
|
|
35
|
+
call('GET', path(slug))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# DELETE /@<slug>/data-streams/user/<slug> — retire the stream.
|
|
39
|
+
def delete(slug)
|
|
40
|
+
call('DELETE', path(slug))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# POST .../<slug>/publish — +data+ is any JSON value; +subject+ is an optional dot-separated routing suffix.
|
|
44
|
+
def publish(slug, data, subject: nil)
|
|
45
|
+
body = { 'data' => data }
|
|
46
|
+
body['subject'] = subject unless subject.nil?
|
|
47
|
+
call('POST', path(slug, 'publish'), body)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# POST .../<slug>/replay — a bounded range of past events: data['events'], data['nextCursor'], data['hasMore'].
|
|
51
|
+
def replay(slug, from_sequence: nil, until_sequence: nil, limit: nil)
|
|
52
|
+
body = {}
|
|
53
|
+
body['fromSequence'] = from_sequence unless from_sequence.nil?
|
|
54
|
+
body['untilSequence'] = until_sequence unless until_sequence.nil?
|
|
55
|
+
body['limit'] = limit unless limit.nil?
|
|
56
|
+
call('POST', path(slug, 'replay'), body)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# GET .../<slug>/subscribe — an Enumerator tailing live events { 'sequence', 'subject', 'data', 'publishedAt' }.
|
|
60
|
+
# The gateway bounds each connection and closes it; the enumerator reconnects from the last sequence it yielded.
|
|
61
|
+
# Stop by leaving the +each+ block (break) or by taking a finite number (+first(n)+).
|
|
62
|
+
def subscribe(slug, from_sequence: nil)
|
|
63
|
+
route = path(slug, 'subscribe')
|
|
64
|
+
Enumerator.new do |yielder|
|
|
65
|
+
cursor = from_sequence
|
|
66
|
+
loop do
|
|
67
|
+
query = cursor.nil? ? nil : { 'fromSequence' => cursor.to_s }
|
|
68
|
+
HttpTransport.stream_sse(config: @config, method: 'GET', path: route, query: query).each do |event|
|
|
69
|
+
next unless event.is_a?(Hash) && event.key?('sequence')
|
|
70
|
+
|
|
71
|
+
cursor = event['sequence']
|
|
72
|
+
yielder << event
|
|
73
|
+
end
|
|
74
|
+
delay = self.class.reconnect_delay
|
|
75
|
+
sleep(delay) if delay.positive?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def call(method, route, body = nil)
|
|
83
|
+
HttpTransport.request(config: @config, method: method, path: route, body: body)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def path(stream_slug = nil, suffix = nil)
|
|
87
|
+
project = Studio.require_project_slug(@config, 'streams')
|
|
88
|
+
sub = +'data-streams/user'
|
|
89
|
+
sub << "/#{::URI.encode_www_form_component(stream_slug)}" if stream_slug
|
|
90
|
+
sub << "/#{suffix}" if suffix
|
|
91
|
+
UrlGrammar.project_path(project, sub)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/k9o/studio.rb
CHANGED
|
@@ -25,7 +25,7 @@ module K9o
|
|
|
25
25
|
slug
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# k9o.studio.workflows
|
|
28
|
+
# k9o.studio.workflows.*
|
|
29
29
|
class Workflows
|
|
30
30
|
attr_reader :runs
|
|
31
31
|
|
|
@@ -34,17 +34,19 @@ module K9o
|
|
|
34
34
|
@runs = Runs.new(config)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
# GET /@<slug>/workflows — the route filters by +status+ and pages with +limit+ / +cursor+.
|
|
38
|
+
def list(status: nil, limit: nil, cursor: nil)
|
|
38
39
|
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
39
40
|
q = {}
|
|
40
41
|
q['status'] = status if status
|
|
41
|
-
q['triggerType'] = trigger_type if trigger_type
|
|
42
42
|
q['limit'] = limit if limit
|
|
43
43
|
q['cursor'] = cursor if cursor
|
|
44
44
|
HttpTransport.request(config: @config, method: 'GET',
|
|
45
45
|
path: UrlGrammar.project_path(slug, 'workflows'), query: q)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# POST /@<slug>/workflows. +workflow+ is the gateway's body: 'name', 'triggerType', 'triggerConfig',
|
|
49
|
+
# 'actions' (each { 'index', 'action_type', 'config', ... }), optional 'description', 'status', 'rbacBinding'.
|
|
48
50
|
def create(workflow)
|
|
49
51
|
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
50
52
|
HttpTransport.request(config: @config, method: 'POST',
|
|
@@ -69,12 +71,12 @@ module K9o
|
|
|
69
71
|
path: UrlGrammar.project_path(slug, "workflows/#{encode(workflow_id)}"))
|
|
70
72
|
end
|
|
71
73
|
|
|
72
|
-
# Manual-trigger a run (
|
|
74
|
+
# Manual-trigger a run (manual workflows only): POST /@<slug>/workflows/<id>/runs { triggerContext }.
|
|
73
75
|
def trigger(workflow_id, trigger_context = {})
|
|
74
76
|
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
75
77
|
HttpTransport.request(config: @config, method: 'POST',
|
|
76
78
|
path: UrlGrammar.project_path(slug, "workflows/#{encode(workflow_id)}/runs"),
|
|
77
|
-
body: trigger_context)
|
|
79
|
+
body: { 'triggerContext' => trigger_context })
|
|
78
80
|
end
|
|
79
81
|
|
|
80
82
|
private
|
|
@@ -83,15 +85,18 @@ module K9o
|
|
|
83
85
|
::URI.encode_www_form_component(s)
|
|
84
86
|
end
|
|
85
87
|
|
|
86
|
-
# k9o.studio.workflows.runs
|
|
88
|
+
# k9o.studio.workflows.runs.*
|
|
87
89
|
class Runs
|
|
88
90
|
def initialize(config)
|
|
89
91
|
@config = config
|
|
90
92
|
end
|
|
91
93
|
|
|
92
|
-
|
|
94
|
+
# GET /@<slug>/workflows/<id>/runs — pages with +limit+ / +cursor+ (the route has no status filter).
|
|
95
|
+
def list(workflow_id, limit: nil, cursor: nil)
|
|
93
96
|
slug = Studio.require_project_slug(@config, 'studio.workflows')
|
|
94
|
-
q =
|
|
97
|
+
q = {}
|
|
98
|
+
q['limit'] = limit if limit
|
|
99
|
+
q['cursor'] = cursor if cursor
|
|
95
100
|
HttpTransport.request(config: @config, method: 'GET',
|
|
96
101
|
path: UrlGrammar.project_path(slug, "workflows/#{enc(workflow_id)}/runs"), query: q)
|
|
97
102
|
end
|
|
@@ -116,15 +121,20 @@ module K9o
|
|
|
116
121
|
end
|
|
117
122
|
end
|
|
118
123
|
|
|
119
|
-
# k9o.studio.dashboards
|
|
124
|
+
# k9o.studio.dashboards.* — widget fields on the wire are snake_case ('widget_type', 'data_source', 'config').
|
|
120
125
|
class Dashboards
|
|
121
126
|
def initialize(config)
|
|
122
127
|
@config = config
|
|
123
128
|
end
|
|
124
129
|
|
|
125
|
-
|
|
130
|
+
# GET /@<slug>/dashboards — filters: +status+, +pinned_only+; pages with +limit+ / +cursor+.
|
|
131
|
+
def list(status: nil, pinned_only: nil, limit: nil, cursor: nil)
|
|
126
132
|
slug = Studio.require_project_slug(@config, 'studio.dashboards')
|
|
127
|
-
q =
|
|
133
|
+
q = {}
|
|
134
|
+
q['status'] = status if status
|
|
135
|
+
q['pinnedOnly'] = pinned_only.to_s unless pinned_only.nil?
|
|
136
|
+
q['limit'] = limit if limit
|
|
137
|
+
q['cursor'] = cursor if cursor
|
|
128
138
|
HttpTransport.request(config: @config, method: 'GET',
|
|
129
139
|
path: UrlGrammar.project_path(slug, 'dashboards'), query: q)
|
|
130
140
|
end
|
data/lib/k9o/url_grammar.rb
CHANGED
|
@@ -7,11 +7,18 @@ module K9o
|
|
|
7
7
|
module UrlGrammar
|
|
8
8
|
SLUG_RE = /\A[a-z][a-z0-9-]{1,62}\z/
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
|
|
10
|
+
# The convenience default when the caller passes no base_url. A default value only: never used to parse a host.
|
|
11
|
+
DEFAULT_BASE_URL = 'https://api.k9o.io'
|
|
12
|
+
|
|
13
|
+
# `api` or `api-staging`, optionally followed by `-<rgn>`: the label that marks a gateway host.
|
|
14
|
+
API_LABEL = /\A(api|api-staging)(?:-([a-z]{2}\d{2}))?\z/.freeze
|
|
15
|
+
|
|
16
|
+
# The structure of a gateway API host, read from the host itself.
|
|
17
|
+
# org_slug: first label of an org host (+acme+ in +acme.api.corp.internal+); nil for a platform host.
|
|
18
|
+
# family: +api+ or +api-staging+.
|
|
19
|
+
# region: 4-character region code when the host is regional (+api-de01+), else nil.
|
|
20
|
+
# apex: everything after the gateway label; never matched against a list.
|
|
21
|
+
ApiHost = Struct.new(:org_slug, :family, :region, :apex)
|
|
15
22
|
|
|
16
23
|
module_function
|
|
17
24
|
|
|
@@ -65,24 +72,43 @@ module K9o
|
|
|
65
72
|
"#{uri.scheme}://#{host_port}"
|
|
66
73
|
end
|
|
67
74
|
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
# (
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
# Read the org slug and region out of a gateway host without knowing its apex. The gateway label is +api+ or
|
|
76
|
+
# +api-staging+, optionally followed by +-<rgn>+; the label before it is the org slug and whatever follows it is
|
|
77
|
+
# the apex. Returns nil for any other shape (localhost, an IP, a single-label host, a host with no gateway
|
|
78
|
+
# label), which callers use exactly as given.
|
|
79
|
+
def parse_api_host(host)
|
|
80
|
+
labels = host.to_s.downcase.split('.', -1)
|
|
81
|
+
return nil if labels.empty? || labels.any?(&:empty?)
|
|
82
|
+
|
|
83
|
+
build = lambda do |gateway_label, org_slug, rest|
|
|
84
|
+
m = API_LABEL.match(gateway_label)
|
|
85
|
+
next nil if m.nil? || rest.empty?
|
|
86
|
+
|
|
87
|
+
ApiHost.new(org_slug, m[1], m[2], rest.join('.'))
|
|
88
|
+
end
|
|
89
|
+
# Org host first: <org>.<api[-rgn]>.<apex>.
|
|
90
|
+
if labels.length >= 3 && labels[0].match?(SLUG_RE) && !labels[0].end_with?('-') && API_LABEL.match?(labels[1])
|
|
91
|
+
return build.call(labels[1], labels[0], labels[2..-1])
|
|
92
|
+
end
|
|
93
|
+
# Platform host: api.<apex>.
|
|
94
|
+
return build.call(labels[0], nil, labels[1..-1]) if labels.length >= 2 && API_LABEL.match?(labels[0])
|
|
95
|
+
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Resolve the effective base_url. base_url is OPTIONAL: omitted, it defaults to DEFAULT_BASE_URL (the public
|
|
100
|
+
# cloud, a convenience only). When the (validated) host is a platform host (+api.<apex>+ or +api-staging.<apex>+,
|
|
101
|
+
# any apex) and org_slug is supplied, the canonical org host +<org_slug>.<host>+ is composed. Any other host (an
|
|
102
|
+
# org host already, an internal or air-gapped host, localhost) passes through unchanged; org_slug is then only
|
|
103
|
+
# sent as a header.
|
|
80
104
|
def resolve_base_url(base_url, org_slug = nil)
|
|
81
|
-
validated = validate_base_url(base_url.nil? ?
|
|
105
|
+
validated = validate_base_url(base_url.nil? ? DEFAULT_BASE_URL : base_url)
|
|
82
106
|
uri = URI.parse(validated)
|
|
83
|
-
|
|
107
|
+
host = parse_api_host(uri.host)
|
|
108
|
+
if !host.nil? && host.org_slug.nil? && host.region.nil? && org_slug
|
|
84
109
|
validate_slug(org_slug, 'org')
|
|
85
|
-
|
|
110
|
+
host_port = uri.port && uri.port != uri.default_port ? "#{uri.host}:#{uri.port}" : uri.host
|
|
111
|
+
return "#{uri.scheme}://#{org_slug}.#{host_port}"
|
|
86
112
|
end
|
|
87
113
|
validated
|
|
88
114
|
end
|