k9o 1.1.1-aarch64-linux → 1.5.0-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 +118 -0
  3. data/README.md +1573 -176
  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 +81 -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/data.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module K9o
4
6
  # Data-plane DSL.
5
7
  #
@@ -17,12 +19,22 @@ module K9o
17
19
  SelectBuilder.new(@config, @collection)
18
20
  end
19
21
 
20
- def insert(record)
22
+ # Insert one record. With +require_approval: true+ the write is sent as +?requireApproval=true+ and waits for
23
+ # approval; the answer is then a queued change (HTTP 202) or, when an +auto_approve+ policy covers the write,
24
+ # the inserted row. Tell them apart with K9o.queued_write?.
25
+ def insert(record = nil, require_approval: false, **fields)
26
+ record = fields if record.nil? # a braceless hash literal arrives as keywords on Ruby 3
21
27
  HttpTransport.request(
22
- config: @config, method: 'POST', path: FromBuilder.collection_path(@config, @collection), body: record
28
+ config: @config, method: 'POST', path: FromBuilder.collection_path(@config, @collection), body: record,
29
+ query: FromBuilder.approval_query(require_approval)
23
30
  )
24
31
  end
25
32
 
33
+ # +{ 'requireApproval' => 'true' }+ for a write that asks to be queued, +nil+ (no query) otherwise.
34
+ def self.approval_query(require_approval)
35
+ require_approval ? { 'requireApproval' => 'true' } : nil
36
+ end
37
+
26
38
  def update(patch)
27
39
  UpdateBuilder.new(@config, @collection, patch)
28
40
  end
@@ -57,6 +69,7 @@ module K9o
57
69
  @collection = collection
58
70
  @filters = []
59
71
  @limit_val = nil
72
+ @require_approval = false
60
73
  @cursor_val = nil
61
74
  @count_mode = nil
62
75
  end
@@ -113,13 +126,104 @@ module K9o
113
126
  end
114
127
 
115
128
  class SelectBuilder < BaseBuilder
129
+ SEARCH_COLUMN = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
130
+ SEARCH_LANGUAGE = /\A[a-zA-Z_]+\z/.freeze
131
+ TEXT_TYPES = %w[plain phrase websearch].freeze
132
+ VECTOR_METRICS = %w[cosine euclidean dot].freeze
133
+
134
+ def initialize(config, collection)
135
+ super
136
+ @search = {}
137
+ @vector = nil
138
+ end
139
+
140
+ # Full-text search over one text column. +type+ is "plain", "phrase" or "websearch".
141
+ def text_search(column, query, language: 'english', type: 'plain')
142
+ check_search_column(column)
143
+ raise search_error('text search query must be a non-empty string') unless query.is_a?(String) && !query.empty?
144
+ unless language.is_a?(String) && language.match?(SEARCH_LANGUAGE)
145
+ raise search_error('text search language must be letters and underscores only')
146
+ end
147
+
148
+ type = type.to_s
149
+ raise search_error("text search type must be one of #{TEXT_TYPES.join(', ')}") unless TEXT_TYPES.include?(type)
150
+
151
+ @search.delete('textSearchLanguage')
152
+ @search.delete('textSearchType')
153
+ @search['textSearch'] = "#{column}:#{query}"
154
+ @search['textSearchLanguage'] = language unless language == 'english'
155
+ @search['textSearchType'] = type unless type == 'plain'
156
+ self
157
+ end
158
+
159
+ # Nearest-neighbour search over one vector column; results come back closest first. The embedding travels in
160
+ # the request body (POST data/<collection>/search), so a real embedding of any length works.
161
+ # +metric+ is "cosine", "euclidean" or "dot"; +limit+ is 1 to 1000 (server default 10). The rows returned are
162
+ # the smaller of this +limit+ and the query's +limit+ (default 50): to get N rows set both.
163
+ # It cannot be combined with +text_search+.
164
+ def vector_search(column, embedding, metric: 'cosine', limit: nil)
165
+ check_search_column(column)
166
+ unless embedding.is_a?(Array) && !embedding.empty? &&
167
+ embedding.all? { |v| v.is_a?(Integer) || (v.is_a?(Float) && v.finite?) }
168
+ raise search_error('embedding must be a non-empty array of finite numbers')
169
+ end
170
+
171
+ metric = metric.to_s
172
+ raise search_error("vector metric must be one of #{VECTOR_METRICS.join(', ')}") unless VECTOR_METRICS.include?(metric)
173
+ unless limit.nil? || (limit.is_a?(Integer) && limit >= 1 && limit <= 1000)
174
+ raise search_error('vector limit must be an integer from 1 to 1000')
175
+ end
176
+
177
+ @vector = { 'column' => column, 'vector' => embedding }
178
+ @vector['metric'] = metric unless metric == 'cosine'
179
+ @vector['limit'] = limit unless limit.nil?
180
+ self
181
+ end
182
+
116
183
  def execute
184
+ return execute_vector_search if @vector
185
+
117
186
  HttpTransport.request(
118
187
  config: @config, method: 'GET',
119
188
  path: FromBuilder.collection_path(@config, @collection),
120
- query: to_query
189
+ query: to_query.merge(@search)
190
+ )
191
+ end
192
+
193
+ private
194
+
195
+ BODY_KEYS = %w[select order limit cursor count or].freeze
196
+
197
+ # POST data/<collection>/search: the vector search entry becomes an object, filters go under +filter+, and the
198
+ # list keys (select, order, limit, cursor, count, or) stay at the top level.
199
+ def execute_vector_search
200
+ raise search_error('text_search cannot be combined with vector_search') if @search.key?('textSearch')
201
+
202
+ body = { 'vectorSearch' => @vector }
203
+ filter = {}
204
+ to_query.each do |key, value|
205
+ if BODY_KEYS.include?(key)
206
+ body[key] = key == 'limit' ? @limit_val : value
207
+ else
208
+ filter[key] = value
209
+ end
210
+ end
211
+ body['filter'] = filter unless filter.empty?
212
+ HttpTransport.request(
213
+ config: @config, method: 'POST',
214
+ path: "#{FromBuilder.collection_path(@config, @collection)}/search", body: body
121
215
  )
122
216
  end
217
+
218
+ def search_error(message)
219
+ KError.new(code: 'K9O_INVALID_FIELD_VALUE', message: message, context: { collection: @collection })
220
+ end
221
+
222
+ def check_search_column(column)
223
+ return if column.is_a?(String) && column.match?(SEARCH_COLUMN)
224
+
225
+ raise search_error('search column must be a column name: letters, digits and underscores, starting with a letter or underscore')
226
+ end
123
227
  end
124
228
 
125
229
  class UpdateBuilder < BaseBuilder
@@ -128,6 +232,13 @@ module K9o
128
232
  @patch = patch
129
233
  end
130
234
 
235
+ # Ask for this update to wait for approval (+?requireApproval=true+). The answer is then a queued change or, when an
236
+ # +auto_approve+ policy covers the write, the updated row: check it with K9o.queued_write?.
237
+ def require_approval
238
+ @require_approval = true
239
+ self
240
+ end
241
+
131
242
  def execute
132
243
  id_filter = @filters.find { |col, op, _| col == 'id' && op == 'eq' }
133
244
  raise KError.new(
@@ -137,11 +248,19 @@ module K9o
137
248
  ) unless id_filter
138
249
 
139
250
  path = "#{FromBuilder.collection_path(@config, @collection)}/#{id_filter[2]}"
140
- HttpTransport.request(config: @config, method: 'PATCH', path: path, body: @patch)
251
+ HttpTransport.request(config: @config, method: 'PATCH', path: path, body: @patch,
252
+ query: FromBuilder.approval_query(@require_approval))
141
253
  end
142
254
  end
143
255
 
144
256
  class DeleteBuilder < BaseBuilder
257
+ # Ask for this delete to wait for approval (+?requireApproval=true+). The answer is then a queued change or, when an
258
+ # +auto_approve+ policy covers the write, the deleted record: check it with K9o.queued_write?.
259
+ def require_approval
260
+ @require_approval = true
261
+ self
262
+ end
263
+
145
264
  def execute
146
265
  id_filter = @filters.find { |col, op, _| col == 'id' && op == 'eq' }
147
266
  raise KError.new(
@@ -151,7 +270,8 @@ module K9o
151
270
  ) unless id_filter
152
271
 
153
272
  path = "#{FromBuilder.collection_path(@config, @collection)}/#{id_filter[2]}"
154
- HttpTransport.request(config: @config, method: 'DELETE', path: path)
273
+ HttpTransport.request(config: @config, method: 'DELETE', path: path,
274
+ query: FromBuilder.approval_query(@require_approval))
155
275
  end
156
276
  end
157
277
  end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module K9o
6
+ # Ed25519 signature verification (RFC 8032). Uses the openssl library when the Ruby in use can verify Ed25519, and
7
+ # an in-process implementation (plain integer arithmetic) otherwise, so the answer never depends on the build of
8
+ # OpenSSL underneath Ruby.
9
+ module Ed25519
10
+ P = (2**255) - 19
11
+ L = (2**252) + 27_742_317_777_372_353_535_851_937_790_883_648_493
12
+ D = (-121_665 * 121_666.pow(P - 2, P)) % P
13
+ SQRT_M1 = 2.pow((P - 1) / 4, P)
14
+ BASE_Y = (4 * 5.pow(P - 2, P)) % P
15
+
16
+ module_function
17
+
18
+ # True when +signature+ (64 bytes) is a valid Ed25519 signature of +message+ under +public_key+ (32 bytes).
19
+ # Malformed input answers false.
20
+ def verify(public_key, message, signature)
21
+ public_key = public_key.b
22
+ message = message.b
23
+ signature = signature.b
24
+ return false unless public_key.bytesize == 32 && signature.bytesize == 64
25
+
26
+ if native?
27
+ verify_native(public_key, message, signature)
28
+ else
29
+ Pure.verify(public_key, message, signature)
30
+ end
31
+ end
32
+
33
+ # True when this Ruby's openssl can verify Ed25519 signatures.
34
+ def native?
35
+ return @native unless @native.nil?
36
+
37
+ @native = begin
38
+ OpenSSL::PKey.respond_to?(:new_raw_public_key) &&
39
+ OpenSSL::PKey.new_raw_public_key('ED25519', ("\x00".b * 31) + "\x01".b).respond_to?(:verify)
40
+ rescue StandardError
41
+ false
42
+ end
43
+ end
44
+
45
+ def verify_native(public_key, message, signature)
46
+ OpenSSL::PKey.new_raw_public_key('ED25519', public_key).verify(nil, signature, message)
47
+ rescue OpenSSL::PKey::PKeyError, ArgumentError
48
+ false
49
+ end
50
+
51
+ # RFC 8032 section 5.1 in plain integers (extended coordinates).
52
+ module Pure
53
+ P = Ed25519::P
54
+ L = Ed25519::L
55
+ D = Ed25519::D
56
+ IDENTITY = [0, 1, 1, 0].freeze
57
+
58
+ module_function
59
+
60
+ def verify(public_key, message, signature)
61
+ a_point = decode_point(public_key)
62
+ return false if a_point.nil?
63
+
64
+ r_bytes = signature.byteslice(0, 32)
65
+ s = int_le(signature.byteslice(32, 32))
66
+ return false unless s < L
67
+ return false if decode_point(r_bytes).nil?
68
+
69
+ k = int_le(sha512(r_bytes + public_key + message)) % L
70
+ # [s]B - [k]A must equal R.
71
+ check = add(scalar_mult(base, s), scalar_mult(negate(a_point), k))
72
+ encode_point(check) == r_bytes
73
+ end
74
+
75
+ # The 32-byte public key for a 32-byte seed. Exposed so tests can sign with a fresh key.
76
+ def public_key(seed)
77
+ encode_point(scalar_mult(base, expand(seed).first))
78
+ end
79
+
80
+ # A 64-byte signature of +message+ by +seed+. Exposed so tests can sign; callers verify with Ed25519.verify.
81
+ def sign(seed, message)
82
+ a, prefix = expand(seed)
83
+ pub = encode_point(scalar_mult(base, a))
84
+ r = int_le(sha512(prefix + message)) % L
85
+ r_bytes = encode_point(scalar_mult(base, r))
86
+ k = int_le(sha512(r_bytes + pub + message)) % L
87
+ r_bytes + le32((r + (k * a)) % L)
88
+ end
89
+
90
+ def expand(seed)
91
+ h = sha512(seed)
92
+ a = int_le(h.byteslice(0, 32))
93
+ a &= (1 << 254) - 8
94
+ a |= 1 << 254
95
+ [a, h.byteslice(32, 32)]
96
+ end
97
+
98
+ def sha512(data)
99
+ OpenSSL::Digest::SHA512.digest(data)
100
+ end
101
+
102
+ def int_le(bytes)
103
+ bytes.unpack('C*').each_with_index.sum { |byte, i| byte << (8 * i) }
104
+ end
105
+
106
+ def le32(int)
107
+ Array.new(32) { |i| (int >> (8 * i)) & 0xff }.pack('C*')
108
+ end
109
+
110
+ def base
111
+ x = recover_x(Ed25519::BASE_Y, 0)
112
+ [x, Ed25519::BASE_Y, 1, (x * Ed25519::BASE_Y) % P]
113
+ end
114
+
115
+ def recover_x(y, sign)
116
+ return nil if y >= P
117
+
118
+ x2 = ((y * y) - 1) * ((D * y * y) + 1).pow(P - 2, P) % P
119
+ if x2.zero?
120
+ return sign.zero? ? 0 : nil
121
+ end
122
+
123
+ x = x2.pow((P + 3) / 8, P)
124
+ x = (x * Ed25519::SQRT_M1) % P unless ((x * x) - x2) % P == 0
125
+ return nil unless ((x * x) - x2) % P == 0
126
+
127
+ x = P - x if (x & 1) != sign
128
+ x
129
+ end
130
+
131
+ def decode_point(bytes)
132
+ return nil unless bytes.bytesize == 32
133
+
134
+ y = int_le(bytes)
135
+ sign = y >> 255
136
+ y &= (1 << 255) - 1
137
+ x = recover_x(y, sign)
138
+ return nil if x.nil?
139
+
140
+ [x, y, 1, (x * y) % P]
141
+ end
142
+
143
+ def encode_point(point)
144
+ x, y, z, = point
145
+ zinv = z.pow(P - 2, P)
146
+ xa = (x * zinv) % P
147
+ ya = (y * zinv) % P
148
+ le32(ya | ((xa & 1) << 255))
149
+ end
150
+
151
+ def add(p1, p2)
152
+ x1, y1, z1, t1 = p1
153
+ x2, y2, z2, t2 = p2
154
+ a = ((y1 - x1) * (y2 - x2)) % P
155
+ b = ((y1 + x1) * (y2 + x2)) % P
156
+ c = (2 * t1 * t2 * D) % P
157
+ d = (2 * z1 * z2) % P
158
+ e = b - a
159
+ f = d - c
160
+ g = d + c
161
+ h = b + a
162
+ [(e * f) % P, (g * h) % P, (f * g) % P, (e * h) % P]
163
+ end
164
+
165
+ def negate(point)
166
+ x, y, z, t = point
167
+ [(-x) % P, y, z, (-t) % P]
168
+ end
169
+
170
+ def scalar_mult(point, scalar)
171
+ result = IDENTITY
172
+ addend = point
173
+ while scalar.positive?
174
+ result = add(result, addend) if scalar.odd?
175
+ addend = add(addend, addend)
176
+ scalar >>= 1
177
+ end
178
+ result
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module K9o
4
+ # End users: k9o.end_users.*, the people who sign in to the project's own apps.
5
+ #
6
+ # list(status:, q:, limit:, cursor:) GET /@<slug>/end-users
7
+ # get(id) GET /@<slug>/end-users/<id>
8
+ # role_grants.list(end_user_id:) GET /@<slug>/end-user-role-grants
9
+ # role_grants.grant(end_user_id:, role:) POST /@<slug>/end-user-role-grants
10
+ # role_grants.revoke(grant_id) DELETE /@<slug>/end-user-role-grants/<grant_id>
11
+ #
12
+ # Listing and reading end users needs the project admin role. Role grants are created and revoked by project
13
+ # admins; reading them is open to a wider set of project roles.
14
+ class EndUsers
15
+ STATUSES = %w[active locked deleted all].freeze
16
+
17
+ attr_reader :role_grants
18
+
19
+ def initialize(config)
20
+ @config = config
21
+ @role_grants = EndUserRoleGrants.new(config)
22
+ end
23
+
24
+ # End users, newest first, one page at a time. +status+ is active, locked, deleted or all (default: active and
25
+ # locked); +q+ is an email prefix; +limit+ is 1 to 200 (default 50).
26
+ def list(status: nil, q: nil, limit: nil, cursor: nil)
27
+ ApprovalChecks.check_limit(limit)
28
+ query = {}
29
+ query['status'] = ApprovalChecks.enum!(status, STATUSES, 'status') unless status.nil?
30
+ query['q'] = q unless q.nil?
31
+ query['limit'] = limit unless limit.nil?
32
+ query['cursor'] = cursor unless cursor.nil?
33
+ HttpTransport.request(config: @config, method: 'GET', path: path('end-users'), query: query)
34
+ end
35
+
36
+ # One end user. An unknown id is a 404 K9O_END_USER_NOT_FOUND.
37
+ def get(id)
38
+ HttpTransport.request(config: @config, method: 'GET',
39
+ path: path("end-users/#{ApprovalChecks.id_segment(id, 'end user')}"))
40
+ end
41
+
42
+ private
43
+
44
+ def path(tail)
45
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'end_users'), tail)
46
+ end
47
+ end
48
+
49
+ # A role is a free string (1 to 128 characters), not a fixed list.
50
+ class EndUserRoleGrants
51
+ def initialize(config)
52
+ @config = config
53
+ end
54
+
55
+ # The active grants, oldest first. Not paginated. +data+ is the array of grants (the gateway wraps it in
56
+ # { 'grants' => [...] }). An unknown +end_user_id+ answers an empty list.
57
+ def list(end_user_id: nil)
58
+ query = {}
59
+ query['endUserId'] = end_user_id unless end_user_id.nil?
60
+ unwrap(HttpTransport.request(config: @config, method: 'GET', path: path, query: query), 'grants')
61
+ end
62
+
63
+ # Give an end user a role; +data+ is the grant. Granting a role the user already holds is a 400
64
+ # K9O_INVALID_FIELD_VALUE; an unknown end user is a 404 K9O_NOT_FOUND. A revoked role can be granted again.
65
+ def grant(end_user_id:, role:)
66
+ body = { 'endUserId' => text!(end_user_id, 'end_user_id', 64), 'role' => text!(role, 'role', 128) }
67
+ unwrap(HttpTransport.request(config: @config, method: 'POST', path: path, body: body), 'grant')
68
+ end
69
+
70
+ # Revoke a grant (it is kept, marked revoked). +data+ is { 'revoked' => true|false, 'revokedAt' => ... }; an
71
+ # unknown or already revoked id is not an error, it answers revoked false and revokedAt nil.
72
+ def revoke(grant_id)
73
+ HttpTransport.request(config: @config, method: 'DELETE',
74
+ path: path("/#{ApprovalChecks.id_segment(grant_id, 'grant')}"))
75
+ end
76
+
77
+ private
78
+
79
+ def unwrap(envelope, key)
80
+ Envelope.new(data: envelope.data.is_a?(Hash) ? envelope.data[key] : envelope.data, meta: envelope.meta)
81
+ end
82
+
83
+ def text!(value, what, max)
84
+ return value if value.is_a?(String) && !value.empty? && value.length <= max
85
+
86
+ raise ApprovalChecks.invalid("#{what} must be a string of 1 to #{max} characters")
87
+ end
88
+
89
+ def path(tail = '')
90
+ UrlGrammar.project_path(Studio.require_project_slug(@config, 'end_users'), "end-user-role-grants#{tail}")
91
+ end
92
+ end
93
+ end
data/lib/k9o/envelope.rb CHANGED
@@ -20,9 +20,10 @@ module K9o
20
20
 
21
21
  # Meta accessor sugar.
22
22
  class Meta
23
- attr_reader :cursor, :subscription, :idempotency, :warnings, :cost, :timing, :context
23
+ attr_reader :cursor, :subscription, :idempotency, :warnings, :cost, :timing, :context, :approval
24
24
 
25
- def initialize(cursor: nil, subscription: nil, idempotency: nil, warnings: [], cost: {}, timing: nil, context: nil)
25
+ def initialize(cursor: nil, subscription: nil, idempotency: nil, warnings: [], cost: {}, timing: nil, context: nil,
26
+ approval: nil)
26
27
  @cursor = cursor
27
28
  @subscription = subscription
28
29
  @idempotency = idempotency
@@ -30,6 +31,7 @@ module K9o
30
31
  @cost = cost
31
32
  @timing = timing
32
33
  @context = context
34
+ @approval = approval
33
35
  end
34
36
 
35
37
  def self.from_wire(wire)
@@ -38,11 +40,12 @@ module K9o
38
40
  idem = wire['idempotency'] ? MetaIdempotency.new(replayed: !!wire['idempotency']['replayed']) : nil
39
41
  warnings = (wire['warnings'] || []).map { |w| MetaWarning.new(code: w['code'] || '', message: w['message'] || '') }
40
42
  timing = wire['timing'] ? MetaTiming.new(total_ms: wire['timing']['totalMs'].to_i) : nil
41
- context = wire['context'] && wire['context']['requestId'] ? MetaContext.new(request_id: wire['context']['requestId']) : nil
43
+ context = wire['context'] && wire['context']['requestId'] ? MetaContext.new(request_id: wire['context']['requestId'], project_id: wire['context']['projectId']) : nil
42
44
  Meta.new(
43
45
  cursor: cursor, subscription: sub, idempotency: idem,
44
46
  warnings: warnings, cost: wire['cost'] || {},
45
- timing: timing, context: context
47
+ timing: timing, context: context,
48
+ approval: wire['approval'].is_a?(Hash) ? wire['approval'] : nil
46
49
  )
47
50
  end
48
51
  end
@@ -60,5 +63,17 @@ module K9o
60
63
  MetaIdempotency = Struct.new(:replayed, keyword_init: true)
61
64
  MetaWarning = Struct.new(:code, :message, keyword_init: true)
62
65
  MetaTiming = Struct.new(:total_ms, keyword_init: true)
63
- MetaContext = Struct.new(:request_id, keyword_init: true)
66
+ MetaContext = Struct.new(:request_id, :project_id, keyword_init: true)
67
+ end
68
+
69
+ module K9o
70
+ # A binary download answer: the bytes exactly as sent and the content type the gateway named (or nil).
71
+ class Download
72
+ attr_reader :body, :content_type
73
+
74
+ def initialize(body:, content_type: nil)
75
+ @body = body
76
+ @content_type = content_type
77
+ end
78
+ end
64
79
  end