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/functions.rb CHANGED
@@ -2,22 +2,56 @@
2
2
 
3
3
  module K9o
4
4
  # Function lifecycle namespace — k9o.functions.*.
5
+ #
6
+ # Upload the function's bytes first (+upload_artifact+ returns the +artifactKey+), then +deploy+ references that
7
+ # artifact. Deploy requires PROJECT_ADMIN and returns with status +deploying+; the function is ready when status
8
+ # flips to +active+.
5
9
  class Functions
10
+ ARTIFACT_CONTENT_TYPES = { 'wasm' => 'application/wasm', 'tar' => 'application/x-tar' }.freeze
11
+
12
+ attr_reader :triggers
13
+
6
14
  def initialize(config)
7
15
  @config = config
16
+ @triggers = FunctionTriggers.new(config)
8
17
  end
9
18
 
10
- def deploy(name:, runtime:, code:, tier: nil, config: nil)
19
+ # POST /@<slug>/functions/artifacts with the raw bytes: a WebAssembly module for the gateway tier (for the platform operator; a project's deploy to it is refused) (up to 16 MiB,
20
+ # +kind+ 'wasm') or an OCI image archive for the sandbox tier (up to 256 MiB, +kind+ 'tar'). Uploading the same
21
+ # bytes again returns the same key. The envelope's data is { 'artifactKey' => String }.
22
+ def upload_artifact(bytes, kind)
23
+ content_type = ARTIFACT_CONTENT_TYPES[kind.to_s]
24
+ raise ArgumentError, "kind must be 'wasm' or 'tar'" if content_type.nil?
25
+
26
+ bytes = bytes.read if bytes.respond_to?(:read)
27
+ raise ArgumentError, 'bytes must be a String or an IO' unless bytes.is_a?(String)
28
+
11
29
  slug = require_project_slug
12
- body = { 'name' => name, 'runtime' => runtime, 'code' => code }
13
- body['tier'] = tier if tier
14
- body['config'] = config if config
30
+ HttpTransport.request(config: @config, method: 'POST',
31
+ path: UrlGrammar.project_path(slug, 'functions/artifacts'),
32
+ raw_body: bytes, headers: { 'content-type' => content_type })
33
+ end
34
+
35
+ # POST /@<slug>/functions — deploy or redeploy from an uploaded artifact.
36
+ # Functions run in the sandbox. tier: 'sandbox' (OCI image archive); runtime: bun python go java kotlin dotnet
37
+ # swift php ruby rust zig. 'gateway' (WebAssembly) is for the platform operator and is refused for a customer
38
+ # deploy; tinygo and assemblyscript run only there, so they cannot be deployed to a project.
39
+ # artifact_key: the key +upload_artifact+ returned; version: label for this deploy (1 to 64 characters)
40
+ def deploy(name:, runtime:, tier:, artifact_key:, version:, description: nil)
41
+ slug = require_project_slug
42
+ body = { 'name' => name, 'runtime' => runtime, 'tier' => tier, 'artifactKey' => artifact_key,
43
+ 'version' => version }
44
+ body['description'] = description unless description.nil?
15
45
  HttpTransport.request(config: @config, method: 'POST', path: UrlGrammar.project_path(slug, 'functions'), body: body)
16
46
  end
17
47
 
18
- def list(status: nil)
48
+ # GET /@<slug>/functions (cursor pagination).
49
+ def list(status: nil, limit: nil, cursor: nil)
19
50
  slug = require_project_slug
20
- q = status ? { 'status' => status } : nil
51
+ q = {}
52
+ q['status'] = status unless status.nil?
53
+ q['limit'] = limit unless limit.nil?
54
+ q['cursor'] = cursor unless cursor.nil?
21
55
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, 'functions'), query: q)
22
56
  end
23
57
 
@@ -26,16 +60,25 @@ module K9o
26
60
  HttpTransport.request(config: @config, method: 'GET', path: UrlGrammar.project_path(slug, "functions/#{enc(name)}"))
27
61
  end
28
62
 
63
+ # Retire a function; its name enters a 24h cooldown.
29
64
  def delete(name)
30
65
  slug = require_project_slug
31
66
  HttpTransport.request(config: @config, method: 'DELETE', path: UrlGrammar.project_path(slug, "functions/#{enc(name)}"))
32
67
  end
33
68
 
34
- # Invoke a function; payload passed to the runtime verbatim.
35
- def invoke(name, payload)
69
+ # POST /@<slug>/functions/<name>/invoke. +payload+ is sent as the +payload+ field of the request body and passed
70
+ # to the function runtime verbatim. Optional controls: +background+ (run asynchronously), +delegation+
71
+ # ({ 'token' => ..., 'invokeSessionId' => ... }), +on_behalf_of+, +confined_to+.
72
+ def invoke(name, payload = nil, background: nil, delegation: nil, on_behalf_of: nil, confined_to: nil)
36
73
  slug = require_project_slug
74
+ body = {}
75
+ body['payload'] = payload unless payload.nil?
76
+ body['background'] = background unless background.nil?
77
+ body['delegation'] = delegation unless delegation.nil?
78
+ body['onBehalfOf'] = on_behalf_of unless on_behalf_of.nil?
79
+ body['confinedTo'] = confined_to unless confined_to.nil?
37
80
  HttpTransport.request(config: @config, method: 'POST',
38
- path: UrlGrammar.project_path(slug, "functions/#{enc(name)}/invoke"), body: payload)
81
+ path: UrlGrammar.project_path(slug, "functions/#{enc(name)}/invoke"), body: body)
39
82
  end
40
83
 
41
84
  private
@@ -48,4 +91,79 @@ module K9o
48
91
  ::URI.encode_www_form_component(s)
49
92
  end
50
93
  end
94
+
95
+ # Event triggers: k9o.functions.triggers.* run a function when a record is inserted, updated, deleted or upserted
96
+ # in a collection. They need the project admin role. For triggers on a timetable see +k9o.schedules+.
97
+ #
98
+ # list(name) GET /@<slug>/functions/<name>/triggers
99
+ # create(name, collection:, events:, ...) POST /@<slug>/functions/<name>/triggers
100
+ # update(name, trigger_id, **fields) PATCH /@<slug>/functions/<name>/triggers/<id>
101
+ # delete(name, trigger_id) DELETE /@<slug>/functions/<name>/triggers/<id> (204, no body)
102
+ class FunctionTriggers
103
+ EVENTS = %w[insert update delete upsert].freeze
104
+ COLLECTION_RE = /\A[a-zA-Z_][a-zA-Z0-9_]{0,127}\z/.freeze
105
+ UPDATE_FIELDS = %i[events filter enabled log_ok_invocations].freeze
106
+
107
+ def initialize(config)
108
+ @config = config
109
+ end
110
+
111
+ # Every trigger of the function, newest first. Not paginated.
112
+ def list(name)
113
+ call('GET', path(name))
114
+ end
115
+
116
+ # Create an event trigger; answers 201 with the trigger. +events+ lists 1 to 4 of insert, update, delete, upsert.
117
+ # +filter+ is a predicate Hash over the record; leave it out to fire on every matching event.
118
+ def create(name, collection:, events:, filter: nil, enabled: nil, log_ok_invocations: nil)
119
+ unless collection.is_a?(String) && COLLECTION_RE.match?(collection)
120
+ raise ApprovalChecks.invalid('collection must match ^[a-zA-Z_][a-zA-Z0-9_]*$ (1 to 128 characters)',
121
+ collection: collection)
122
+ end
123
+
124
+ body = { 'collection' => collection, 'events' => check_events(events) }
125
+ body['filter'] = filter unless filter.nil?
126
+ body['enabled'] = enabled unless enabled.nil?
127
+ body['logOkInvocations'] = log_ok_invocations unless log_ok_invocations.nil?
128
+ call('POST', path(name), body: body)
129
+ end
130
+
131
+ # Change a trigger; send at least one of +events+, +filter+, +enabled+, +log_ok_invocations+. A Hash +filter+
132
+ # replaces the filter and an explicit +filter: nil+ clears it. The collection cannot be changed.
133
+ def update(name, trigger_id, **fields)
134
+ unknown = fields.keys - UPDATE_FIELDS
135
+ raise ApprovalChecks.invalid("unknown field: #{unknown.join(', ')}") unless unknown.empty?
136
+ raise ApprovalChecks.invalid("patch needs at least one of: #{UPDATE_FIELDS.join(', ')}") if fields.empty?
137
+
138
+ body = {}
139
+ body['events'] = check_events(fields[:events]) if fields.key?(:events)
140
+ body['filter'] = fields[:filter] if fields.key?(:filter)
141
+ body['enabled'] = fields[:enabled] if fields.key?(:enabled)
142
+ body['logOkInvocations'] = fields[:log_ok_invocations] if fields.key?(:log_ok_invocations)
143
+ call('PATCH', path(name, "/#{ApprovalChecks.id_segment(trigger_id, 'trigger')}"), body: body)
144
+ end
145
+
146
+ # Delete a trigger. Answers 204, so +data+ is nil; deleting again is a 404.
147
+ def delete(name, trigger_id)
148
+ call('DELETE', path(name, "/#{ApprovalChecks.id_segment(trigger_id, 'trigger')}"))
149
+ end
150
+
151
+ private
152
+
153
+ def check_events(events)
154
+ list = events.is_a?(Array) ? events.map { |e| e.is_a?(Symbol) ? e.to_s : e } : nil
155
+ return list if list && list.size.between?(1, 4) && list.all? { |e| EVENTS.include?(e) }
156
+
157
+ raise ApprovalChecks.invalid('events must list 1 to 4 of insert, update, delete, upsert', events: events)
158
+ end
159
+
160
+ def call(method, route, body: nil)
161
+ HttpTransport.request(config: @config, method: method, path: route, body: body)
162
+ end
163
+
164
+ def path(name, tail = '')
165
+ slug = Studio.require_project_slug(@config, 'functions')
166
+ UrlGrammar.project_path(slug, "functions/#{ApprovalChecks.id_segment(name, 'function')}/triggers#{tail}")
167
+ end
168
+ end
51
169
  end
data/lib/k9o/gate.rb CHANGED
@@ -1,64 +1,94 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module K9o
4
- # Gate (Identity-Aware Kernel) namespace. Project-scoped.
4
+ # Gate (Identity-Aware Kernel) namespace. Project-scoped; the gateway closes each body to the fields shown.
5
5
  class Gate
6
6
  def initialize(config)
7
7
  @config = config
8
8
  end
9
9
 
10
+ # POST /@<slug>/gate/signup { email, password, displayName? }
10
11
  def sign_up(email:, password:, display_name: nil)
11
12
  body = { 'email' => email, 'password' => password }
12
13
  body['displayName'] = display_name if display_name
13
14
  HttpTransport.request(config: @config, method: 'POST', path: gate_path('signup'), body: body)
14
15
  end
15
16
 
17
+ # POST /@<slug>/gate/signin { email, password }
18
+ #
19
+ # Asks the project which region holds the email first (POST /@<slug>/gate/locate, the email only, no credential),
20
+ # then sends the password to that region's host if it is a host of your own org and apex, else to the base URL.
21
+ # A 429 or 503 from that step raises as any gateway error does, nothing else is sent, and it is safe to retry.
16
22
  def sign_in(email:, password:)
17
- HttpTransport.request(
23
+ origin = Locate.origin_for(@config, email, gate_path('locate'))
24
+ out = HttpTransport.request(
18
25
  config: @config, method: 'POST', path: gate_path('signin'),
19
- body: { 'email' => email, 'password' => password }
26
+ body: { 'email' => email, 'password' => password }, origin: origin
20
27
  )
28
+ Locate.remember(session_refresh_token(out), origin)
29
+ out
21
30
  end
22
31
 
23
- def sign_out(refresh_token: nil)
24
- body = refresh_token ? { 'refreshToken' => refresh_token } : {}
25
- HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'), body: body)
32
+ # POST /@<slug>/gate/signout { refreshToken } — the refresh token is required (the gateway answers 400 without it).
33
+ def sign_out(refresh_token:)
34
+ out = HttpTransport.request(config: @config, method: 'POST', path: gate_path('signout'),
35
+ body: { 'refreshToken' => refresh_token }, origin: Locate.hint(refresh_token))
36
+ Locate.forget(refresh_token)
37
+ out
26
38
  end
27
39
 
40
+ # GET /@<slug>/gate/whoami
28
41
  def whoami
29
42
  HttpTransport.request(config: @config, method: 'GET', path: gate_path('whoami'))
30
43
  end
31
44
 
45
+ # POST /@<slug>/auth/sessions/revoke-all
46
+ #
47
+ # Ends every live session of the signed-in end user, on every device, this one included. Call it with the end
48
+ # user's access token as the client's `api_key` (the token `whoami` uses), not a refresh token. The answer's `data`
49
+ # is `{ revokedCount }`. Each ended session's next refresh raises a KError with code `K9O_SESSION_EXPIRED` and a
50
+ # `reason` of `"revoked"` in its `context`; access tokens already issued keep working until they expire. An
51
+ # operator's token raises `K9O_TRUST_ZONE_VIOLATION` (403), and a token issued for another project
52
+ # `K9O_PROJECT_SCOPE_MISMATCH` (403).
53
+ def revoke_all_sessions
54
+ HttpTransport.request(config: @config, method: 'POST', path: project_path('auth/sessions/revoke-all'))
55
+ end
56
+
57
+ # POST /@<slug>/gate/refresh { refreshToken }
58
+ #
59
+ # The answer's `data` is `{ accessToken, refreshToken, expiresAt }`. The refresh token you sent stops working: store
60
+ # the returned `refreshToken` before the next refresh, and refresh one at a time per session. A second call with a
61
+ # secret that was already used is a replay: the session is revoked and the call raises a KError with code
62
+ # `K9O_SESSION_EXPIRED` and a `reason` of `"replay"` in its `context`. The newer secret then gets `"revoked"`. The
63
+ # other reasons are `"inactive"` and `"max_length"`; sign in again.
32
64
  def refresh_token(refresh_token:)
33
- HttpTransport.request(
65
+ out = HttpTransport.request(
34
66
  config: @config, method: 'POST', path: gate_path('refresh'),
35
- body: { 'refreshToken' => refresh_token }
67
+ body: { 'refreshToken' => refresh_token }, origin: Locate.hint(refresh_token)
36
68
  )
69
+ Locate.rotate(refresh_token, out.respond_to?(:data) ? out.data : nil)
70
+ out
37
71
  end
38
72
 
39
- def verify_session
40
- HttpTransport.request(config: @config, method: 'GET', path: gate_path('verify'))
41
- end
73
+ private
42
74
 
43
- # The project's hosted sign-in URL and WebAuthn rpId:
44
- # GET /@<slug>/gate/hosted. Unauthenticated and cacheable; no credential is
45
- # sent even when an api_key is configured. Returns an Envelope whose `data`
46
- # is { 'signInUrl' => String, 'rpId' => String }. Take both values as given:
47
- # an unknown org, a deleted project and a non-Gate project all answer the
48
- # same 404 K9O_PROJECT_NOT_FOUND.
49
- def hosted
50
- HttpTransport.request(config: @config, method: 'GET', path: gate_path('hosted'), authenticated: false)
75
+ def session_refresh_token(envelope)
76
+ data = envelope.respond_to?(:data) ? envelope.data : nil
77
+ session = data.is_a?(Hash) ? data['session'] : nil
78
+ session.is_a?(Hash) ? session['refreshToken'] : nil
51
79
  end
52
80
 
53
- private
54
-
55
81
  def gate_path(action)
82
+ project_path("gate/#{action}")
83
+ end
84
+
85
+ def project_path(suffix)
56
86
  raise KError.new(
57
87
  code: 'K9O_MISSING_PROJECT_CONTEXT',
58
88
  message: 'k9o.gate operations require project_slug bound on the client',
59
89
  context: {}
60
90
  ) if @config.project_slug.nil? || @config.project_slug.empty?
61
- UrlGrammar.project_path(@config.project_slug, "gate/#{action}")
91
+ UrlGrammar.project_path(@config.project_slug, suffix)
62
92
  end
63
93
  end
64
94
  end
@@ -29,12 +29,13 @@ module K9o
29
29
  # Per-instance HTTP config bound on K9oClient.
30
30
  class HttpClientConfig
31
31
  attr_reader :base_url, :api_key, :org_slug, :project_slug, :default_headers, :http_client, :retry_config,
32
- :region_mutex
32
+ :region_mutex, :expected_key_refs
33
33
  # Cached regional origin (see K9o::Region); nil until a definite answer.
34
34
  attr_accessor :regional_origin
35
35
 
36
36
  def initialize(base_url:, api_key: nil, org_slug: nil, project_slug: nil, default_headers: {}, http_client: nil,
37
- retry_config: nil)
37
+ retry_config: nil, expected_key_refs: nil)
38
+ @expected_key_refs = expected_key_refs
38
39
  @base_url = base_url
39
40
  @api_key = api_key
40
41
  @org_slug = org_slug
@@ -49,6 +50,8 @@ module K9o
49
50
 
50
51
  # HTTP transport using Net::HTTP stdlib.
51
52
  module HttpTransport
53
+ # Seconds to wait for a response, as in the Python SDK (120 s read). A long call, such as Rampart `classify` on a long input, can run about 20 s.
54
+ DEFAULT_READ_TIMEOUT = 120
52
55
  RETRYABLE_METHODS = %w[GET HEAD OPTIONS DELETE].freeze
53
56
  RETRYABLE_STATUS = [502, 503, 504, 429].freeze
54
57
 
@@ -68,13 +71,32 @@ module K9o
68
71
  #
69
72
  # `authenticated: false` sends NO credential: no api_key bearer, and any
70
73
  # authorization / cookie header from `default_headers` or `headers:` is dropped.
71
- def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil)
74
+ #
75
+ # `origin:` sends the request to that origin instead of the client's regional base. Only the end-user credential
76
+ # flows set it, after the rule in K9o::Locate.
77
+ #
78
+ # `bare: true` is for the few public feeds that answer `{ ...fields, meta }` without a `data` wrapper (the Rampart
79
+ # key feed): the fields come back as `data`.
80
+ #
81
+ # `binary: true` is for download routes: a 2xx answer comes back as a K9o::Download (bytes and content type),
82
+ # not parsed; an error answer is still the JSON error envelope.
83
+ def request(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
84
+ raw_body: nil, bare: false, binary: false, origin: nil)
72
85
  retry_with_backoff(method, config.retry_config || RetryConfig.default) do
73
86
  request_once(config: config, method: method, path: path, body: body, query: query, headers: headers,
74
- authenticated: authenticated, multipart: multipart)
87
+ authenticated: authenticated, multipart: multipart, raw_body: raw_body, bare: bare,
88
+ binary: binary, origin: origin)
75
89
  end
76
90
  end
77
91
 
92
+ # A response header, or nil (also for response doubles that have no header table).
93
+ def header_of(response, name)
94
+ value = response[name]
95
+ value.is_a?(String) ? value : nil
96
+ rescue NameError, IndexError, TypeError
97
+ nil
98
+ end
99
+
78
100
  # Run a block with retry-with-backoff. Extracted so the retry policy is
79
101
  # unit-testable without a live HTTP round-trip.
80
102
  def retry_with_backoff(method, retry_config)
@@ -116,8 +138,9 @@ module K9o
116
138
  end
117
139
 
118
140
  # Single-attempt request. `request` wraps this in retry-with-backoff.
119
- def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil)
120
- url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
141
+ def request_once(config:, method:, path:, body: nil, query: nil, headers: nil, authenticated: true, multipart: nil,
142
+ raw_body: nil, bare: false, binary: false, origin: nil)
143
+ url = "#{(origin || Region.resolve_origin(config)).sub(%r{/\z}, '')}#{path}"
121
144
  if query && !query.empty?
122
145
  url += '?' + URI.encode_www_form(query)
123
146
  end
@@ -125,7 +148,7 @@ module K9o
125
148
  uri = URI.parse(url)
126
149
  http = config.http_client || ::Net::HTTP.new(uri.host, uri.port)
127
150
  http.use_ssl = (uri.scheme == 'https')
128
- http.read_timeout = 30
151
+ http.read_timeout = DEFAULT_READ_TIMEOUT
129
152
 
130
153
  req_class = case method.to_s.upcase
131
154
  when 'GET' then ::Net::HTTP::Get
@@ -149,7 +172,10 @@ module K9o
149
172
  req.delete('cookie')
150
173
  end
151
174
 
152
- if multipart
175
+ if raw_body
176
+ # Binary routes: the bytes go unchanged; the caller supplies the content-type in +headers+.
177
+ req.body = raw_body
178
+ elsif multipart
153
179
  # multipart/form-data: the encoder sets the boundary; never force JSON.
154
180
  data, content_type = Multipart.encode(multipart)
155
181
  req['content-type'] = content_type
@@ -174,6 +200,10 @@ module K9o
174
200
 
175
201
  return Envelope.new(data: nil, meta: Meta.new) if status == 204
176
202
 
203
+ if binary && status >= 200 && status < 300
204
+ return Download.new(body: (response.body || '').b, content_type: header_of(response, 'content-type'))
205
+ end
206
+
177
207
  begin
178
208
  parsed = JSON.parse(response.body || '')
179
209
  rescue JSON::ParserError => e
@@ -181,11 +211,17 @@ module K9o
181
211
  code: 'K9O_INTERNAL_ERROR',
182
212
  message: "Failed to parse JSON response from #{url}: #{e.message}",
183
213
  context: { url: url, status: status },
184
- status: status
214
+ status: status,
215
+ request_id: KError.request_id_from_header(header_of(response, 'x-k9o-request-id'))
185
216
  )
186
217
  end
187
218
 
188
219
  if status >= 200 && status < 300
220
+ if bare && parsed.is_a?(Hash)
221
+ return Envelope.new(data: parsed.reject { |key, _| key == 'meta' },
222
+ meta: Meta.from_wire(parsed['meta'].is_a?(Hash) ? parsed['meta'] : {}))
223
+ end
224
+
189
225
  unless parsed.is_a?(Hash) && parsed.key?('data') && parsed.key?('meta')
190
226
  raise KError.new(
191
227
  code: 'K9O_INTERNAL_ERROR',
@@ -198,14 +234,16 @@ module K9o
198
234
  end
199
235
 
200
236
  if parsed.is_a?(Hash) && parsed['code'].is_a?(String) && parsed['message'].is_a?(String)
201
- raise KError.from_wire(parsed, status)
237
+ raise KError.from_wire(parsed, status, retry_after_header: header_of(response, 'retry-after'),
238
+ request_id_header: header_of(response, 'x-k9o-request-id'))
202
239
  end
203
240
 
204
241
  raise KError.new(
205
242
  code: 'K9O_INTERNAL_ERROR',
206
243
  message: "Upstream returned #{status} with non-KError body",
207
244
  context: { url: url, status: status },
208
- status: status
245
+ status: status,
246
+ request_id: KError.request_id_from_header(header_of(response, 'x-k9o-request-id'))
209
247
  )
210
248
  end
211
249
 
@@ -215,9 +253,9 @@ module K9o
215
253
  # sentinel terminates the stream; non-2xx raises KError before any
216
254
  # event is yielded.
217
255
  #
218
- # Per the Ruby async-iteration idiom is Enumerator — caller
256
+ # The Ruby async-iteration idiom is Enumerator — caller
219
257
  # writes `client.compute.llms.invoke_stream(...).each { |chunk| ... }`.
220
- def stream_sse(config:, method:, path:, body: nil, query: nil)
258
+ def stream_sse(config:, method:, path:, body: nil, query: nil, headers: nil, idle_timeout: nil, raw: false)
221
259
  url = "#{Region.resolve_origin(config).sub(%r{/\z}, '')}#{path}"
222
260
  if query && !query.empty?
223
261
  url += '?' + URI.encode_www_form(query)
@@ -227,7 +265,8 @@ module K9o
227
265
  Enumerator.new do |yielder|
228
266
  http = config.http_client || ::Net::HTTP.new(uri.host, uri.port)
229
267
  http.use_ssl = (uri.scheme == 'https')
230
- http.read_timeout = 300
268
+ # The read timeout is a per-read idle limit, not a total limit on the stream.
269
+ http.read_timeout = idle_timeout || 300
231
270
 
232
271
  req_class = case method.to_s.upcase
233
272
  when 'GET' then ::Net::HTTP::Get
@@ -236,6 +275,7 @@ module K9o
236
275
  end
237
276
  req = req_class.new(uri.request_uri)
238
277
  req['accept'] = 'text/event-stream'
278
+ headers&.each { |k, v| req[k] = v }
239
279
  config.default_headers.each { |k, v| req[k] = v }
240
280
  req['x-k9o-project-slug'] = config.project_slug if config.project_slug
241
281
  req['x-k9o-org-slug'] = config.org_slug if config.org_slug
@@ -257,20 +297,34 @@ module K9o
257
297
  nil
258
298
  end
259
299
  if parsed.is_a?(Hash) && parsed['code'].is_a?(String) && parsed['message'].is_a?(String)
260
- raise KError.from_wire(parsed, status)
300
+ raise KError.from_wire(parsed, status, retry_after_header: header_of(response, 'retry-after'),
301
+ request_id_header: header_of(response, 'x-k9o-request-id'))
261
302
  end
262
303
 
263
304
  raise KError.new(
264
305
  code: 'K9O_INTERNAL_ERROR',
265
306
  message: "Upstream returned #{status} opening stream",
266
307
  context: { url: url, status: status },
267
- status: status
308
+ status: status,
309
+ request_id: KError.request_id_from_header(header_of(response, 'x-k9o-request-id'))
268
310
  )
269
311
  end
270
312
 
271
313
  buffer = +''
272
314
  data_lines = []
315
+ event_name = nil
316
+ event_id = nil
317
+ retry_ms = nil
273
318
  flush = lambda do
319
+ if raw
320
+ # Raw mode yields every frame as { event:, id:, data:, retry: } (comment lines are dropped).
321
+ had = !data_lines.empty? || event_name || event_id || retry_ms
322
+ frame = { event: event_name, id: event_id, data: data_lines.empty? ? nil : data_lines.join("\n"), retry: retry_ms }
323
+ data_lines = []
324
+ event_name = event_id = retry_ms = nil
325
+ yielder << frame if had
326
+ next true
327
+ end
274
328
  return true if data_lines.empty?
275
329
 
276
330
  payload = data_lines.join("\n")
@@ -297,6 +351,14 @@ module K9o
297
351
  rest = line[5..]
298
352
  rest = rest[1..] if rest.start_with?(' ')
299
353
  data_lines << rest
354
+ elsif raw && line.start_with?('event:', 'id:', 'retry:')
355
+ field, rest = line.split(':', 2)
356
+ rest = rest[1..] if rest.start_with?(' ')
357
+ case field
358
+ when 'event' then event_name = rest
359
+ when 'id' then event_id = rest
360
+ else retry_ms = Integer(rest, exception: false)
361
+ end
300
362
  end
301
363
  end
302
364
  end
data/lib/k9o/jcs.rb ADDED
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # The platform's canonical JSON form (the Kilimanjaro JCS code-point profile), implemented here with no dependency.
5
+ #
6
+ # K9o::Jcs.canonicalize({ "b" => 2, "a" => [1, 3] }) # => '{"a":[1,3],"b":2}'
7
+ #
8
+ # Object members are sorted by UTF-8 byte order (the same as Unicode code-point order), there is no whitespace,
9
+ # strings are escaped minimally (+"+ and +\+, the short escapes for backspace, tab, newline, form feed and carriage
10
+ # return, every other control character as lowercase +\u00xx+; +/+ and every other character are written as they
11
+ # are), and numbers are integers only: base 10, no exponent, +-0+ written +0+. A fractional number, NaN, an infinity
12
+ # or an integer whose absolute value is above 2^53 - 1 raises, so a value that cannot be hashed identically
13
+ # everywhere never gets a hash.
14
+ module Jcs
15
+ # Raised for a value that has no canonical form. +kind+ is :non_integer_number, :numeric_ceiling_exceeded or
16
+ # :unsupported_value.
17
+ class Error < ArgumentError
18
+ attr_reader :kind
19
+
20
+ def initialize(message, kind = :unsupported_value)
21
+ super(message)
22
+ @kind = kind
23
+ end
24
+ end
25
+
26
+ ESCAPES = {
27
+ '"' => '\\"', '\\' => '\\\\', "\b" => '\\b', "\t" => '\\t', "\n" => '\\n', "\f" => '\\f', "\r" => '\\r'
28
+ }.freeze
29
+ MAX_SAFE_INTEGER = 9_007_199_254_740_991
30
+
31
+ module_function
32
+
33
+ # The canonical form of +value+ as a UTF-8 String.
34
+ def canonicalize(value)
35
+ out = +''
36
+ write(value, out)
37
+ out.force_encoding(Encoding::UTF_8)
38
+ end
39
+
40
+ # The canonical form as bytes (UTF-8), the preimage to hash.
41
+ def canonical_bytes(value)
42
+ canonicalize(value).b
43
+ end
44
+
45
+ def write(value, out)
46
+ case value
47
+ when nil then out << 'null'
48
+ when true then out << 'true'
49
+ when false then out << 'false'
50
+ when Integer then out << number(value)
51
+ when Float then out << number(value)
52
+ when String then write_string(value, out)
53
+ when Symbol then write_string(value.to_s, out)
54
+ when Array
55
+ out << '['
56
+ value.each_with_index do |item, index|
57
+ out << ',' if index.positive?
58
+ write(item, out)
59
+ end
60
+ out << ']'
61
+ when Hash then write_object(value, out)
62
+ else
63
+ raise Error, "#{value.class} has no JSON form"
64
+ end
65
+ end
66
+
67
+ def write_object(hash, out)
68
+ members = hash.map { |key, val| [key.is_a?(Symbol) ? key.to_s : key, val] }
69
+ members.each do |key, _|
70
+ raise Error, 'object keys must be strings' unless key.is_a?(String)
71
+ end
72
+ keys = members.map(&:first)
73
+ raise Error, 'object has duplicate keys' unless keys.uniq.size == keys.size
74
+
75
+ sorted = members.sort_by { |key, _| key.b }
76
+ out << '{'
77
+ sorted.each_with_index do |(key, val), index|
78
+ out << ',' if index.positive?
79
+ write_string(key, out)
80
+ out << ':'
81
+ write(val, out)
82
+ end
83
+ out << '}'
84
+ end
85
+
86
+ def write_string(string, out)
87
+ text = string.encoding == Encoding::UTF_8 ? string : string.encode(Encoding::UTF_8)
88
+ raise Error, 'string is not valid UTF-8' unless text.valid_encoding?
89
+
90
+ out << '"'
91
+ text.each_char do |ch|
92
+ if (esc = ESCAPES[ch])
93
+ out << esc
94
+ elsif ch.ord < 0x20
95
+ out << format('\\u%04x', ch.ord)
96
+ else
97
+ out << ch
98
+ end
99
+ end
100
+ out << '"'
101
+ end
102
+
103
+ # Integers only, within +/-(2^53 - 1). A float that is a whole number is written as that integer (a JSON reader
104
+ # that parses +3.0+ sees the integer 3).
105
+ def number(num)
106
+ if num.is_a?(Float)
107
+ raise Error.new('NaN and Infinity have no canonical form', :non_integer_number) unless num.finite?
108
+ raise Error.new('a fractional number has no canonical form', :non_integer_number) unless num == num.floor
109
+
110
+ num = num.to_i
111
+ end
112
+ raise Error.new("integer #{num} is above 2^53 - 1", :numeric_ceiling_exceeded) if num.abs > MAX_SAFE_INTEGER
113
+
114
+ num.to_s
115
+ end
116
+ end
117
+ end