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.
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 (ALL-LIVE primitive).
4
+ # Realtime Subscriptions namespace.
5
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.
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 transport (primary); returns an
17
- # Enumerator yielding event hashes.
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
- 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
- )
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
- # 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),
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
- # - Only a region-free org host is ever probed; any other base URL is used
17
- # exactly as given.
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 `<same org>.<same apex family>-<rgn>.k9o.io` (rgn = [a-z]{2}\d{2}).
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
- m = REGION_FREE_ORG_HOST.match(host)
32
- return nil if m.nil?
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
- r = /\A#{org}\.#{apex}-([a-z0-9]+)\.k9o\.io\z/.match(h)
47
- return nil if r.nil? || !REGION_CODE.match?(r[1])
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
- return [configured, true] unless REGION_FREE_ORG_HOST.match?(host)
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
@@ -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
- def list(status: nil, trigger_type: nil, limit: nil, cursor: nil)
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 (trigger_type=manual only).
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
- def list(workflow_id, status: nil)
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 = status ? { 'status' => status } : nil
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
- def list(status: nil)
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 = status ? { 'status' => status } : nil
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
@@ -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
- # 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
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
- # 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>. 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.
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? ? 'https://api.k9o.io' : base_url)
105
+ validated = validate_base_url(base_url.nil? ? DEFAULT_BASE_URL : base_url)
82
106
  uri = URI.parse(validated)
83
- if ORG_DERIVABLE_APEXES.include?(uri.host) && org_slug
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
- return "#{uri.scheme}://#{org_slug}.#{uri.host}"
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