k9o 1.2.0-aarch64-linux → 1.5.2-aarch64-linux

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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +123 -1
  3. data/README.md +1602 -180
  4. data/k9o.gemspec +2 -2
  5. data/lib/k9o/_ffi/kibo_kms_rest_ffi.rb +928 -25
  6. data/lib/k9o/_ffi/libkibo_kms_rest_ffi.so +0 -0
  7. data/lib/k9o/ai.rb +15 -0
  8. data/lib/k9o/analytics.rb +403 -0
  9. data/lib/k9o/approvals.rb +253 -0
  10. data/lib/k9o/audit.rb +162 -14
  11. data/lib/k9o/client.rb +28 -9
  12. data/lib/k9o/compute.rb +32 -15
  13. data/lib/k9o/crypto.rb +471 -0
  14. data/lib/k9o/custody.rb +28 -0
  15. data/lib/k9o/data.rb +125 -5
  16. data/lib/k9o/ed25519.rb +182 -0
  17. data/lib/k9o/end_users.rb +93 -0
  18. data/lib/k9o/envelope.rb +20 -5
  19. data/lib/k9o/functions.rb +127 -9
  20. data/lib/k9o/gate.rb +52 -22
  21. data/lib/k9o/http_transport.rb +78 -16
  22. data/lib/k9o/jcs.rb +117 -0
  23. data/lib/k9o/kerror.rb +57 -5
  24. data/lib/k9o/keys.rb +131 -0
  25. data/lib/k9o/kibo.rb +3 -3
  26. data/lib/k9o/kibo_client_core.rb +385 -0
  27. data/lib/k9o/kibo_ffi_core.rb +178 -0
  28. data/lib/k9o/locate.rb +118 -0
  29. data/lib/k9o/me.rb +27 -87
  30. data/lib/k9o/metadata.rb +342 -0
  31. data/lib/k9o/moat.rb +443 -0
  32. data/lib/k9o/org.rb +80 -68
  33. data/lib/k9o/orgs.rb +1 -0
  34. data/lib/k9o/policies.rb +25 -6
  35. data/lib/k9o/projects.rb +43 -8
  36. data/lib/k9o/query.rb +174 -0
  37. data/lib/k9o/rampart.rb +273 -0
  38. data/lib/k9o/realtime.rb +261 -32
  39. data/lib/k9o/region.rb +20 -18
  40. data/lib/k9o/schedules.rb +109 -0
  41. data/lib/k9o/schema.rb +35 -0
  42. data/lib/k9o/streams.rb +94 -0
  43. data/lib/k9o/studio.rb +21 -11
  44. data/lib/k9o/url_grammar.rb +46 -20
  45. data/lib/k9o/vault.rb +79 -84
  46. data/lib/k9o/vectors.rb +124 -0
  47. data/lib/k9o/version.rb +1 -1
  48. data/lib/k9o.rb +17 -0
  49. data/sig/k9o/ai.rbs +4 -0
  50. data/sig/k9o/approvals.rbs +71 -0
  51. data/sig/k9o/datasets.rbs +125 -0
  52. data/sig/k9o/gate.rbs +9 -6
  53. data/sig/k9o/kerror.rbs +24 -0
  54. data/sig/k9o/kibo_client_core.rbs +25 -0
  55. data/sig/k9o/metadata.rbs +54 -0
  56. data/sig/k9o/moat.rbs +29 -0
  57. data/sig/k9o/query.rbs +26 -0
  58. data/sig/k9o/rampart.rbs +59 -0
  59. data/sig/k9o/realtime.rbs +34 -0
  60. data/sig/k9o/region.rbs +14 -3
  61. data/sig/k9o/workload.rbs +64 -0
  62. metadata +33 -4
data/lib/k9o/org.rb CHANGED
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module K9o
4
- # Org-plane bound namespace — k9o.org.*. Singular
5
- # bound-org namespace (distinct from k9o.orgs collection): members /
6
- # groups / smtp / audit_log / gate (roles + idp_providers) + default_llm.
7
- # Org-plane URL grammar routes against the org subdomain
8
- # without the @<projectSlug>/ prefix.
4
+ # Org-plane bound namespace — k9o.org.*. Singular bound-org namespace (distinct from the k9o.orgs collection):
5
+ # members / groups / smtp / audit_log / gate (roles + idp_providers) + default_llm.
6
+ # Org-plane routes live on the org host without the @<projectSlug>/ prefix.
9
7
  class Org
10
8
  attr_reader :members, :groups, :smtp, :audit_log, :gate, :default_llm
11
9
 
@@ -19,7 +17,7 @@ module K9o
19
17
  @default_llm = DefaultLlm.new(config)
20
18
  end
21
19
 
22
- # Read the current org (slug + name + status + settings).
20
+ # GET /org — the current org (slug + name + status).
23
21
  def get
24
22
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org'))
25
23
  end
@@ -34,64 +32,57 @@ module K9o
34
32
  @config = config
35
33
  end
36
34
 
37
- def list(status: nil)
38
- q = status ? { 'status' => status } : nil
35
+ # GET /members. +status+ is the stored membership status ('active' or 'suspended'); +acceptance_status+ is
36
+ # 'accepted' or 'pending'.
37
+ def list(user_id: nil, role: nil, acceptance_status: nil, status: nil, state: nil)
38
+ q = {}
39
+ q['userId'] = user_id unless user_id.nil?
40
+ q['role'] = role unless role.nil?
41
+ q['acceptanceStatus'] = acceptance_status unless acceptance_status.nil?
42
+ q['status'] = status unless status.nil?
43
+ q['state'] = state unless state.nil?
39
44
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('members'), query: q)
40
45
  end
41
46
 
47
+ # POST /members { email, role }
42
48
  def invite(email:, role:)
43
49
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('members'),
44
50
  body: { 'email' => email, 'role' => role })
45
51
  end
46
52
 
47
- def get(user_id)
48
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("members/#{Org.enc(user_id)}"))
49
- end
50
-
51
53
  def remove(user_id)
52
54
  HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("members/#{Org.enc(user_id)}"))
53
55
  end
54
56
  end
55
57
 
56
- # k9o.org.groups.*
58
+ # k9o.org.groups.* (read-only: the gateway has no group create or delete)
57
59
  class Groups
58
60
  def initialize(config)
59
61
  @config = config
60
62
  end
61
63
 
62
- def list
63
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('groups'))
64
- end
65
-
66
- def create(name:)
67
- HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('groups'), body: { 'name' => name })
64
+ # GET /groups — optionally only the groups +user_id+ belongs to.
65
+ def list(user_id: nil)
66
+ q = user_id.nil? ? nil : { 'userId' => user_id }
67
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('groups'), query: q)
68
68
  end
69
69
 
70
70
  def get(group_id)
71
71
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("groups/#{Org.enc(group_id)}"))
72
72
  end
73
-
74
- def delete(group_id)
75
- HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("groups/#{Org.enc(group_id)}"))
76
- end
77
73
  end
78
74
 
79
- # k9o.org.audit_log.* (org-plane; distinct from project Audit).
75
+ # k9o.org.audit_log.* (org-plane; distinct from project Audit). The org audit route reads only +type+ (exact
76
+ # event type) and +limit+ (1 to 500, default 100).
80
77
  class AuditLog
81
78
  def initialize(config)
82
79
  @config = config
83
80
  end
84
81
 
85
- def query(since: nil, until_: nil, type: nil, actor_id: nil, target_kind: nil, target_id: nil, limit: nil, cursor: nil)
82
+ def query(type: nil, limit: nil)
86
83
  q = {}
87
- q['since'] = since if since
88
- q['until'] = until_ if until_
89
- q['type'] = type if type
90
- q['actorId'] = actor_id if actor_id
91
- q['targetKind'] = target_kind if target_kind
92
- q['targetId'] = target_id if target_id
93
- q['limit'] = limit if limit
94
- q['cursor'] = cursor if cursor
84
+ q['type'] = type unless type.nil?
85
+ q['limit'] = limit unless limit.nil?
95
86
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('audit-log'), query: q)
96
87
  end
97
88
  end
@@ -105,88 +96,109 @@ module K9o
105
96
  @idp_providers = IdpProviders.new(config)
106
97
  end
107
98
 
108
- # k9o.org.gate.roles.*
99
+ # k9o.org.gate.roles.* — org role grants.
109
100
  class Roles
110
101
  def initialize(config)
111
102
  @config = config
112
103
  end
113
104
 
114
- def list(user_id: nil, role: nil)
115
- q = {}
116
- q['userId'] = user_id if user_id
117
- q['role'] = role if role
118
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org-role-grants'), query: q)
105
+ # GET /role-grants (active grants).
106
+ def list
107
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('role-grants'))
119
108
  end
120
109
 
121
- def grant(user_id:, role:)
122
- HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('org-role-grants'),
123
- body: { 'userId' => user_id, 'role' => role })
110
+ # POST /role-grants { userId, role, decisionReason? }
111
+ def grant(user_id:, role:, decision_reason: nil)
112
+ body = { 'userId' => user_id, 'role' => role }
113
+ body['decisionReason'] = decision_reason unless decision_reason.nil?
114
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('role-grants'), body: body)
124
115
  end
125
116
 
126
117
  def get(grant_id)
127
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("org-role-grants/#{Org.enc(grant_id)}"))
118
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("role-grants/#{Org.enc(grant_id)}"))
128
119
  end
129
120
 
130
- def revoke(grant_id)
131
- HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("org-role-grants/#{Org.enc(grant_id)}"))
121
+ # DELETE /role-grants/<id> with an optional { decisionReason } body.
122
+ def revoke(grant_id, decision_reason: nil)
123
+ body = decision_reason.nil? ? nil : { 'decisionReason' => decision_reason }
124
+ HttpTransport.request(config: @config, method: 'DELETE',
125
+ path: UrlGrammar.org_path("role-grants/#{Org.enc(grant_id)}"), body: body)
132
126
  end
133
127
  end
134
128
 
135
- # k9o.org.gate.idp_providers.*
129
+ # k9o.org.gate.idp_providers.* — the org's identity providers (/identity-providers).
136
130
  class IdpProviders
131
+ CREATE_OPTIONAL = {
132
+ redirect_uri: 'redirectUri', metadata_url: 'metadataUrl', attribute_map: 'attributeMap', enabled: 'enabled'
133
+ }.freeze
134
+
137
135
  def initialize(config)
138
136
  @config = config
139
137
  end
140
138
 
141
139
  def list
142
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('idp-providers'))
140
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('identity-providers'))
143
141
  end
144
142
 
145
- def create(name:, kind:, config:)
146
- HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('idp-providers'),
147
- body: { 'name' => name, 'kind' => kind, 'config' => config })
143
+ # POST /identity-providers { idpProvider, displayName, clientId, clientSecretRef, ... }
144
+ # idp_provider: google github microsoft oidc saml scim ldap active_directory apple facebook twitter
145
+ # client_secret_ref: the OAuth client secret itself (the name is kept for compatibility); the gateway encrypts it and never returns it
146
+ def create(idp_provider:, display_name:, client_id:, client_secret_ref:, **opts)
147
+ body = { 'idpProvider' => idp_provider, 'displayName' => display_name, 'clientId' => client_id,
148
+ 'clientSecretRef' => client_secret_ref }
149
+ opts.each do |key, value|
150
+ wire = CREATE_OPTIONAL[key]
151
+ raise ArgumentError, "unknown idp_providers.create option: #{key}" if wire.nil?
152
+
153
+ body[wire] = value
154
+ end
155
+ HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('identity-providers'), body: body)
148
156
  end
149
157
 
150
158
  def get(idp_id)
151
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("idp-providers/#{Org.enc(idp_id)}"))
159
+ HttpTransport.request(config: @config, method: 'GET',
160
+ path: UrlGrammar.org_path("identity-providers/#{Org.enc(idp_id)}"))
152
161
  end
153
162
 
163
+ # PATCH /identity-providers/<id>. +patch+ keys are the gateway's: displayName, clientId, clientSecretRef,
164
+ # redirectUri, metadataUrl, attributeMap, enabled.
154
165
  def configure(idp_id, patch)
155
166
  HttpTransport.request(config: @config, method: 'PATCH',
156
- path: UrlGrammar.org_path("idp-providers/#{Org.enc(idp_id)}"), body: patch)
167
+ path: UrlGrammar.org_path("identity-providers/#{Org.enc(idp_id)}"), body: patch)
157
168
  end
158
169
 
159
170
  def delete(idp_id)
160
- HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.org_path("idp-providers/#{Org.enc(idp_id)}"))
171
+ HttpTransport.request(config: @config, method: 'DELETE',
172
+ path: UrlGrammar.org_path("identity-providers/#{Org.enc(idp_id)}"))
161
173
  end
162
174
  end
163
175
  end
164
176
 
165
- # k9o.org.smtp.*
177
+ # k9o.org.smtp.* — the org's SMTP relay, stored in the org record (PATCH /org { smtpConfig }).
166
178
  class Smtp
167
179
  def initialize(config)
168
180
  @config = config
169
181
  end
170
182
 
171
- def get
172
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org/smtp'))
173
- end
174
-
175
- def set(host:, port:, username:, password:, from_email:, from_name:, encryption:, reply_to_email: nil)
183
+ # PATCH /org { smtpConfig: { host, port, username, passwordRef, fromAddress, fromName? } }.
184
+ # +password_ref+ references the stored SMTP password; it is never the password itself.
185
+ def set(host:, port:, username:, password_ref:, from_address:, from_name: nil)
176
186
  smtp = {
177
- 'host' => host, 'port' => port, 'username' => username, 'password' => password,
178
- 'fromEmail' => from_email, 'fromName' => from_name, 'encryption' => encryption
187
+ 'host' => host, 'port' => port, 'username' => username, 'passwordRef' => password_ref,
188
+ 'fromAddress' => from_address
179
189
  }
180
- smtp['replyToEmail'] = reply_to_email if reply_to_email
190
+ smtp['fromName'] = from_name unless from_name.nil?
181
191
  HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('org'), body: { 'smtpConfig' => smtp })
182
192
  end
183
193
 
184
- def test(to:, from_override: nil)
194
+ # POST /org/smtp/test-email { to, subject? }
195
+ def test(to:, subject: nil)
185
196
  body = { 'to' => to }
186
- body['fromOverride'] = from_override if from_override
197
+ body['subject'] = subject unless subject.nil?
187
198
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('org/smtp/test-email'), body: body)
188
199
  end
189
200
 
201
+ # PATCH /org { smtpConfig: null }
190
202
  def unset
191
203
  HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path('org'), body: { 'smtpConfig' => nil })
192
204
  end
@@ -198,15 +210,15 @@ module K9o
198
210
  @config = config
199
211
  end
200
212
 
201
- # Read the org-default LLM model id (nil when unset → mistral-7b).
213
+ # GET /default-llm
202
214
  def get
203
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('org/default-llm'))
215
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('default-llm'))
204
216
  end
205
217
 
206
- # Set the org-default LLM (ORG_ADMIN; tier-gated).
218
+ # PATCH /default-llm { modelId }
207
219
  def set(model_id)
208
220
  HttpTransport.request(config: @config, method: 'PATCH',
209
- path: UrlGrammar.org_path('org/default-llm'), body: { 'modelId' => model_id })
221
+ path: UrlGrammar.org_path('default-llm'), body: { 'modelId' => model_id })
210
222
  end
211
223
  end
212
224
  end
data/lib/k9o/orgs.rb CHANGED
@@ -13,6 +13,7 @@ module K9o
13
13
  end
14
14
 
15
15
  def get(org_slug)
16
+ UrlGrammar.validate_slug(org_slug, 'org')
16
17
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("orgs/#{enc(org_slug)}"))
17
18
  end
18
19
 
data/lib/k9o/policies.rb CHANGED
@@ -2,11 +2,18 @@
2
2
 
3
3
  module K9o
4
4
  # Policy bundle lifecycle namespace — k9o.policies.*.
5
+ #
6
+ # The policy document is a Hash of sections keyed by resource kind ('collections', 'storage', 'tools',
7
+ # 'functions', 'secrets', 'streams', 'analytics'; other keys are allowed). Only authoring roles write policy and
8
+ # only custodian roles publish; +validate+ is read-only.
5
9
  class Policies
10
+ EDITOR_MODES = %w[visual json unspecified].freeze
11
+
6
12
  def initialize(config)
7
13
  @config = config
8
14
  end
9
15
 
16
+ # GET /@<slug>/policy — the currently active policy (data is nil when none is published).
10
17
  def current
11
18
  slug = require_project_slug
12
19
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'policy'))
@@ -18,16 +25,18 @@ module K9o
18
25
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'policies'), query: q)
19
26
  end
20
27
 
21
- def create(name:, rules:)
28
+ # POST /@<slug>/policies — create a draft bundle from a policy document.
29
+ def create(policy:, rules: nil, editor_mode: nil)
22
30
  slug = require_project_slug
23
31
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'policies'),
24
- body: { 'name' => name, 'rules' => rules })
32
+ body: policy_body(policy, rules, editor_mode))
25
33
  end
26
34
 
27
- def validate(rules)
35
+ # POST /@<slug>/policies/validate — lint a candidate policy document without persisting.
36
+ def validate(policy:, rules: nil)
28
37
  slug = require_project_slug
29
38
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'policies/validate'),
30
- body: { 'rules' => rules })
39
+ body: policy_body(policy, rules, nil))
31
40
  end
32
41
 
33
42
  def get(bundle_id)
@@ -35,12 +44,15 @@ module K9o
35
44
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"))
36
45
  end
37
46
 
38
- def update(bundle_id, patch)
47
+ # PATCH /@<slug>/policies/<id> — replace a draft bundle's policy document. Published bundles cannot be patched.
48
+ def update(bundle_id, policy:, rules: nil, editor_mode: nil)
39
49
  slug = require_project_slug
40
50
  HttpTransport.request(config: @config, method: 'PATCH',
41
- path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"), body: patch)
51
+ path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"),
52
+ body: policy_body(policy, rules, editor_mode))
42
53
  end
43
54
 
55
+ # Delete a draft bundle. Published bundles cannot be deleted.
44
56
  def delete(bundle_id)
45
57
  slug = require_project_slug
46
58
  HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.project_path(slug, "policies/#{enc(bundle_id)}"))
@@ -54,6 +66,13 @@ module K9o
54
66
 
55
67
  private
56
68
 
69
+ def policy_body(policy, rules, editor_mode)
70
+ body = { 'policy' => policy }
71
+ body['rules'] = rules unless rules.nil?
72
+ body['editorMode'] = editor_mode unless editor_mode.nil?
73
+ body
74
+ end
75
+
57
76
  def require_project_slug
58
77
  Studio.require_project_slug(@config, 'policies')
59
78
  end
data/lib/k9o/projects.rb CHANGED
@@ -2,32 +2,67 @@
2
2
 
3
3
  module K9o
4
4
  # Project lifecycle namespace — k9o.projects.* (Ruby snake_case).
5
+ #
6
+ # All four operations are org-plane: the client needs an org context (an
7
+ # +org_slug+ or a bearer token that carries one). Option names are snake_case
8
+ # spellings of the gateway's body property names.
5
9
  class Projects
10
+ CREATE_FIELDS = {
11
+ slug: 'slug', product_scope: 'productScope', region: 'region',
12
+ default_project_region: 'defaultProjectRegion', custody_mode: 'custodyMode',
13
+ processing_contexts: 'processingContexts', exec_seam_confined: 'execSeamConfined',
14
+ showroom_vertical: 'showroomVertical', persona: 'persona', showroom_locale: 'showroomLocale',
15
+ idempotency_key: 'idempotencyKey'
16
+ }.freeze
17
+
18
+ UPDATE_FIELDS = {
19
+ slug: 'slug', display_name: 'displayName', description: 'description', region: 'region',
20
+ product_scope: 'productScope', soft_delete_retention_days: 'softDeleteRetentionDays',
21
+ processing_contexts: 'processingContexts'
22
+ }.freeze
23
+
6
24
  def initialize(config)
7
25
  @config = config
8
26
  end
9
27
 
28
+ # GET /projects
10
29
  def list
11
30
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path('projects'))
12
31
  end
13
32
 
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
33
+ # POST /projects. +display_name+ is required (1 to 128 characters); the gateway derives a slug from it when
34
+ # +slug+ is omitted. +custody_mode+ is one of PLATFORM_MANAGED, SKR, BYOK, HYOK. +idempotency_key+ makes a
35
+ # retried create return the first result instead of a second project.
36
+ def create(display_name:, **opts)
37
+ body = { 'displayName' => display_name }
38
+ opts.each do |key, value|
39
+ wire = CREATE_FIELDS[key]
40
+ raise ArgumentError, "unknown projects.create option: #{key}" if wire.nil?
41
+
42
+ body[wire] = value unless value.nil?
43
+ end
44
+ UrlGrammar.validate_slug(opts[:slug], 'project') unless opts[:slug].nil?
18
45
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.org_path('projects'), body: body)
19
46
  end
20
47
 
48
+ # GET /projects/<slug>
21
49
  def get(slug)
22
50
  UrlGrammar.validate_slug(slug, 'project')
23
- HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'project'))
51
+ HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.org_path("projects/#{slug}"))
24
52
  end
25
53
 
26
- def update(slug, name: nil)
54
+ # PATCH /projects/<slug>. Pass only the fields to change; an explicit +nil+ is sent as null
55
+ # (for +description+ that clears it).
56
+ def update(slug, **fields)
27
57
  UrlGrammar.validate_slug(slug, 'project')
28
58
  patch = {}
29
- patch['name'] = name if name
30
- HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.project_path(slug, 'project'), body: patch)
59
+ fields.each do |key, value|
60
+ wire = UPDATE_FIELDS[key]
61
+ raise ArgumentError, "unknown projects.update field: #{key}" if wire.nil?
62
+
63
+ patch[wire] = value
64
+ end
65
+ HttpTransport.request(config: @config, method: 'PATCH', path: UrlGrammar.org_path("projects/#{slug}"), body: patch)
31
66
  end
32
67
  end
33
68
  end
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