bynn-decisionnode 0.4.1

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.
@@ -0,0 +1,535 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ class DecisionNode
7
+ # Collections of faces, a workspace's own and the public ones it may use
8
+ # (`celebrities` is an id like any other), and knowledge bases: collections
9
+ # of type "text" holding a workspace's own documents, which the `knowledge`
10
+ # booster reads. Every call returns the API's object as sent, a Hash with
11
+ # the API's field names; #list returns the collections in the order sent.
12
+ # One Hash shape serves both kinds, told apart by its "type".
13
+ #
14
+ # `create` and `enroll` send an Idempotency-Key, a new UUID for each call and
15
+ # the same on every attempt of it, so the API creates, enrolls and bills once
16
+ # however often the call is tried. They retry as every call does, and also
17
+ # on a 409 `idempotency_in_progress` after its Retry-After; a 409
18
+ # `idempotency_key_reused` is raised. `update_item` carries a key the same
19
+ # way, as do #add_document and #replace_document. Reads and deletes carry
20
+ # no key. An answer the API replays for a repeated key is returned as sent.
21
+ class Collections
22
+ NAME_MAX = 80
23
+ REFERENCE_MAX = 64
24
+ LABEL_MAX = 128
25
+ # The API's pattern, as its 422 names it, and the same rule matched on the
26
+ # whole string (Ruby's ^ and $ match at line ends). An empty reference
27
+ # fails the pattern, as the API answers it.
28
+ REFERENCE_PATTERN = "^[A-Za-z0-9_.:-]{1,64}$"
29
+ REFERENCE_RULE = /\A[A-Za-z0-9_.:-]{1,64}\z/
30
+ ECHO_CHARS = 64
31
+ METADATA_KEYS = 20
32
+ METADATA_KEY_CHARS = 40
33
+ METADATA_TEXT_CHARS = 500
34
+ METADATA_KEY_RULE = /\A[a-z0-9_]{1,40}\z/
35
+ SCOPE_KEY_PATTERN = "^[a-z0-9_]{1,40}$"
36
+ SCOPE_KEY_RULE = /\A[a-z0-9_]{1,40}\z/
37
+ TITLE_MAX = 200
38
+ PIN_RANK_MAX = 1_000
39
+ # wait_until_ready: the longest wait between reads, and how much longer
40
+ # each wait is than the one before.
41
+ POLL_CAP = 10.0
42
+ POLL_GROWTH = 1.5
43
+ MIN_POLL_INTERVAL = 0.1
44
+ # list_items' search text, in characters; its orders; and how much of a
45
+ # wrong order the API's message echoes.
46
+ Q_MAX = 200
47
+ SORTS = %w[newest oldest title].freeze
48
+ SORT_ECHO = 16
49
+ # The value of an argument not given, so a label given as nil is sent as
50
+ # null, which clears it.
51
+ UNSET = Object.new.freeze
52
+ private_constant :NAME_MAX, :REFERENCE_MAX, :LABEL_MAX, :REFERENCE_PATTERN, :REFERENCE_RULE, :ECHO_CHARS,
53
+ :METADATA_KEYS, :METADATA_KEY_CHARS, :METADATA_TEXT_CHARS, :METADATA_KEY_RULE, :UNSET,
54
+ :SCOPE_KEY_PATTERN, :SCOPE_KEY_RULE, :TITLE_MAX, :PIN_RANK_MAX, :POLL_CAP, :POLL_GROWTH,
55
+ :MIN_POLL_INTERVAL, :Q_MAX, :SORTS, :SORT_ECHO
56
+
57
+ def initialize(client)
58
+ @client = client
59
+ end
60
+
61
+ def inspect
62
+ "#<#{self.class.name}>"
63
+ end
64
+
65
+ # Creates a collection (`POST /v1/collections`). `type` is "face" (faces)
66
+ # or "text" (a knowledge base); `name` is 1 to 80 characters. A knowledge
67
+ # base may take a `scope_key`, 1 to 40 of a-z, 0-9 and _, fixed for its
68
+ # life: the metadata key whose value scopes a document to, say, one
69
+ # mission. It is sent only when given.
70
+ def create(name:, type: "face", scope_key: nil)
71
+ type = type.to_s if type.is_a?(Symbol)
72
+ entries = []
73
+ unless %w[face text].include?(type)
74
+ entries << Checks.entry("literal_error", %w[body type], "Input should be 'face' or 'text'", echoed(type),
75
+ { "expected" => "'face' or 'text'" })
76
+ end
77
+ entries.concat(string_entries(%w[body name], name, 1, NAME_MAX))
78
+ unless scope_key.nil? || (scope_key.is_a?(String) && scope_key.match?(SCOPE_KEY_RULE))
79
+ entries << Checks.entry("string_pattern_mismatch", %w[body scope_key],
80
+ "String should match pattern '#{SCOPE_KEY_PATTERN}'", echoed(scope_key))
81
+ end
82
+ raise Checks.invalid(entries) unless entries.empty?
83
+
84
+ body = { "type" => type, "name" => name }
85
+ body["scope_key"] = scope_key unless scope_key.nil?
86
+ call("POST", "/v1/collections", body, key: SecureRandom.uuid)
87
+ end
88
+
89
+ # The workspace's collections, then the public ones it may use
90
+ # (`GET /v1/collections`), as an Array in the order sent.
91
+ def list
92
+ body = call("GET", "/v1/collections")
93
+ body.is_a?(Hash) ? body["collections"] : body
94
+ end
95
+
96
+ # `GET /v1/collections/{id}`.
97
+ def get(collection_id)
98
+ call("GET", "/v1/collections/#{segment(collection_id)}")
99
+ end
100
+
101
+ # `DELETE /v1/collections/{id}`. A knowledge base answers
102
+ # `{"id", "deleted" => true}` once purged within 10 s, else
103
+ # `{"id", "status" => "purging"}` while the purge finishes in the
104
+ # background; both are success.
105
+ def delete(collection_id)
106
+ call("DELETE", "/v1/collections/#{segment(collection_id)}")
107
+ end
108
+
109
+ # Enrolls one face from one image (`POST /v1/collections/{id}/items`). The
110
+ # image is sent as a decision sends one: a URL as given, local bytes inline
111
+ # under the inline threshold, else uploaded. Only a URL's rules are checked
112
+ # before sending; any other fault of the picture is the API's 422
113
+ # `image_unreadable`. `reference` is 1 to 64 of A-Z, a-z, 0-9 and _ . : -;
114
+ # `label`, when given, at most 128 characters. `metadata`, when given, is a
115
+ # Hash of at most 20 keys of 1 to 40 of a-z, 0-9 and _, each value a text of
116
+ # at most 500 characters, a number, true or false.
117
+ def enroll(collection_id, image:, reference:, label: nil, metadata: nil)
118
+ entries = []
119
+ entries << Checks.entry("missing", %w[body image], "Field required", nil) if image.nil?
120
+ entries.concat(string_entries(%w[body reference], reference, 1, REFERENCE_MAX, REFERENCE_RULE))
121
+ entries.concat(string_entries(%w[body label], label, 1, LABEL_MAX)) unless label.nil?
122
+ entries.concat(metadata_entries(metadata)) unless metadata.nil?
123
+ raise Checks.invalid(entries) unless entries.empty?
124
+
125
+ images = @client.__send__(:prepared_images, [image])
126
+ Checks.image_url!(image.id, image.url) if image.is_a?(Image) && image.url
127
+ path = "/v1/collections/#{segment(collection_id)}/items"
128
+ 2.times do |attempt|
129
+ wires, placed = @client.__send__(:image_wires, images)
130
+ body = { "image" => wires.first, "reference" => reference }
131
+ body["label"] = label unless label.nil?
132
+ body["metadata"] = metadata unless metadata.nil?
133
+ begin
134
+ # A resend after a stale upload carries a new upload id, so it is a
135
+ # new request with its own key.
136
+ return call("POST", path, body, key: SecureRandom.uuid)
137
+ rescue BadRequestError => e
138
+ raise unless attempt.zero? && !placed.empty? && Uploads::STALE_UPLOAD.include?(e.error_type)
139
+
140
+ @client.__send__(:drop_uploads, placed)
141
+ end
142
+ end
143
+ end
144
+
145
+ # Edits an item (`PATCH /v1/collections/{id}/items/{item_id}`), sending
146
+ # only the fields given: a `label` given as nil clears it, and `metadata`
147
+ # replaces the item's whole object ({} clears it). A document also takes
148
+ # `pinned`, `pin_rank` (0 to 1,000) and `title` (1 to 200 characters):
149
+ # each is applied at once, never re-embedded or billed, except a new title,
150
+ # which makes a new version. The reference and the label are checked as
151
+ # #enroll checks them. It carries an Idempotency-Key and retries as
152
+ # #create and #enroll do. Returns the item as sent.
153
+ def update_item(collection_id, item_id, reference: UNSET, label: UNSET, metadata: UNSET, pinned: UNSET,
154
+ pin_rank: UNSET, title: UNSET)
155
+ given = { "reference" => reference, "label" => label, "metadata" => metadata, "pinned" => pinned,
156
+ "pin_rank" => pin_rank, "title" => title }.reject { |_, v| v.equal?(UNSET) }
157
+ entries = document_entries(given)
158
+ entries << Checks.entry("missing", %w[body], "Send reference, label, metadata or any of them", nil) if given.empty?
159
+ raise Checks.invalid(entries) unless entries.empty?
160
+
161
+ call("PATCH", "/v1/collections/#{segment(collection_id)}/items/#{segment(item_id)}", given,
162
+ key: SecureRandom.uuid)
163
+ end
164
+
165
+ # Adds a document to a knowledge base (`POST /v1/collections/{id}/items`)
166
+ # and returns the item as sent: "queued", or with `wait: true` (the
167
+ # server holds the call up to 10 s) "ready" once it is, else as it stands.
168
+ #
169
+ # dn.collections.add_document(kb, document: DecisionNode::Document.new(path: "policy.pdf"), wait: true)
170
+ #
171
+ # document:: a DecisionNode::Document. A text, and a text or Markdown file
172
+ # under the client's inline threshold, are sent inline; any
173
+ # other file is uploaded first and named by its upload id.
174
+ # title:: 1 to 200 characters; required for a text, else a file's name
175
+ # without its extension by default.
176
+ # reference, label, metadata:: as #enroll's. The knowledge base's scope
177
+ # key in `metadata` scopes the document; without it the
178
+ # document is global.
179
+ # pinned:: true: always given to a request that names the knowledge base.
180
+ # pin_rank:: 0 to 1,000, the order of pinned documents, lowest first.
181
+ #
182
+ # Each field is sent only when given. It carries an Idempotency-Key, a new
183
+ # UUID for each call and the same on every attempt of it. An upload this
184
+ # call made that the API calls stale is uploaded again and the call sent
185
+ # once more, with a new key; an upload id you gave is never re-uploaded.
186
+ def add_document(collection_id, document:, title: nil, reference: nil, pinned: nil, pin_rank: nil, label: nil,
187
+ metadata: nil, wait: false)
188
+ fields = { "title" => title, "reference" => reference, "pinned" => pinned, "pin_rank" => pin_rank,
189
+ "label" => label, "metadata" => metadata }.compact
190
+ document_write("POST", "/v1/collections/#{segment(collection_id)}/items", document, fields, wait)
191
+ end
192
+
193
+ # Replaces a document whole (`PUT /v1/collections/{id}/items/{item_id}`),
194
+ # with #add_document's arguments. A new text, format or title is a new
195
+ # version, the old one serving until the new one is ready; the same text
196
+ # again changes nothing. Returns the item as sent.
197
+ def replace_document(collection_id, item_id, document:, title: nil, reference: nil, pinned: nil, pin_rank: nil,
198
+ label: nil, metadata: nil, wait: false)
199
+ fields = { "title" => title, "reference" => reference, "pinned" => pinned, "pin_rank" => pin_rank,
200
+ "label" => label, "metadata" => metadata }.compact
201
+ document_write("PUT", "/v1/collections/#{segment(collection_id)}/items/#{segment(item_id)}", document, fields,
202
+ wait)
203
+ end
204
+
205
+ # One item (`GET /v1/collections/{id}/items/{item_id}`); with
206
+ # `include_text: true` a document's stored text too (nil while no version
207
+ # serves). Byte offsets in chunks and passages index that text's UTF-8 bytes.
208
+ def get_item(collection_id, item_id, include_text: false)
209
+ path = "/v1/collections/#{segment(collection_id)}/items/#{segment(item_id)}"
210
+ call("GET", include_text == true ? "#{path}?include=text" : path)
211
+ end
212
+
213
+ # One page of a document's live chunks
214
+ # (`GET /v1/collections/{id}/items/{item_id}/chunks`), `offset` and `limit`
215
+ # (1 to 100) sent only when given.
216
+ def list_chunks(collection_id, item_id, offset: nil, limit: nil)
217
+ Checks.list!(offset, limit)
218
+ query = { "offset" => offset, "limit" => limit }.compact
219
+ path = "/v1/collections/#{segment(collection_id)}/items/#{segment(item_id)}/chunks"
220
+ call("GET", query.empty? ? path : "#{path}?#{URI.encode_www_form(query)}")
221
+ end
222
+
223
+ # Deletes every failed document of a knowledge base
224
+ # (`DELETE /v1/collections/{id}/items?status=failed`) and returns the
225
+ # answer as sent, `{"deleted" => n}`.
226
+ def delete_failed(collection_id)
227
+ call("DELETE", "/v1/collections/#{segment(collection_id)}/items?status=failed")
228
+ end
229
+
230
+ # Waits on the client for documents to be ingested. With an `item_id` it
231
+ # reads the item until its status is "ready" or "failed" and returns it,
232
+ # whichever it is (a failed item says why in its "error"). Without one it
233
+ # reads the knowledge base until no document is queued or processing and
234
+ # returns it.
235
+ #
236
+ # The first read is made at once; then it waits `poll_interval` seconds
237
+ # (at least 0.1, default 1), half as long again after each read up to 10
238
+ # s. Each read retries as any call does, after the Retry-After of a 429 or
239
+ # 529. Past `timeout` seconds (default 300) it raises
240
+ # DecisionNode::TimeoutError.
241
+ def wait_until_ready(collection_id, item_id = nil, timeout: 300, poll_interval: 1.0)
242
+ unless poll_interval.is_a?(Numeric) && poll_interval.finite? && poll_interval >= MIN_POLL_INTERVAL
243
+ raise ArgumentError, "poll_interval must be at least #{MIN_POLL_INTERVAL} seconds, got #{poll_interval.inspect}"
244
+ end
245
+ unless timeout.is_a?(Numeric) && timeout.positive?
246
+ raise ArgumentError, "timeout must be a number of seconds above 0, got #{timeout.inspect}"
247
+ end
248
+
249
+ interval = poll_interval.to_f
250
+ waited = 0.0
251
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
252
+ loop do
253
+ found = item_id.nil? ? get(collection_id) : get_item(collection_id, item_id)
254
+ return found if settled?(found, item_id)
255
+
256
+ elapsed = [waited, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started].max
257
+ if elapsed >= timeout
258
+ what = item_id.nil? ? "the documents of #{collection_id} were" : "the document #{item_id} was"
259
+ raise TimeoutError, "#{what} not ready within #{timeout} s"
260
+ end
261
+
262
+ # The last wait ends at the timeout, for one more read.
263
+ wait = [interval, timeout - elapsed].min
264
+ @client.__send__(:pause, wait)
265
+ waited += wait
266
+ interval = [interval * POLL_GROWTH, POLL_CAP].min
267
+ end
268
+ end
269
+
270
+ # Retrieves passages without a decision (`POST /v1/collections/{id}/search`),
271
+ # billed as one knowledge call (a full-scope key). `query` is required;
272
+ # `scope`, `match`, `filter`, `top_k`, `pinned` and `max_tokens` (an
273
+ # Integer or "auto") are sent only when given, as given, as the knowledge
274
+ # booster's options. Returns a KnowledgeReading with every passage's text
275
+ # (an UnknownReading holding the answer when it has a field missing or of
276
+ # the wrong type).
277
+ def search(collection_id, query:, scope: nil, match: nil, filter: nil, top_k: nil, pinned: nil, max_tokens: nil)
278
+ body = { "query" => query, "scope" => scope, "match" => match, "filter" => filter, "top_k" => top_k,
279
+ "pinned" => pinned, "max_tokens" => max_tokens }.reject { |k, v| v.nil? && k != "query" }
280
+ Reading.from("knowledge", call("POST", "/v1/collections/#{segment(collection_id)}/search", body))
281
+ end
282
+
283
+ # One page of a collection's items (`GET /v1/collections/{id}/items`),
284
+ # with `offset`, `limit`, `reference` (the items of that reference),
285
+ # `reference_prefix` (the items whose reference starts with it) sent only
286
+ # when given; `reference` and `reference_prefix` not both. A knowledge
287
+ # base also takes `q` (1 to 200 characters, matched without regard to
288
+ # case against a document's title, file name or reference) and `sort`
289
+ # ("newest", "oldest" or "title"), each sent only when given.
290
+ def list_items(collection_id, offset: nil, limit: nil, reference: nil, reference_prefix: nil, q: nil, sort: nil)
291
+ Checks.list!(offset, limit)
292
+ unless reference.nil? || reference_prefix.nil?
293
+ Checks.usage("Send query parameter reference or reference_prefix, not both.")
294
+ end
295
+
296
+ query = {}
297
+ query["offset"] = offset unless offset.nil?
298
+ query["limit"] = limit unless limit.nil?
299
+ { "reference" => reference, "reference_prefix" => reference_prefix }.each do |name, value|
300
+ next if value.nil?
301
+
302
+ reference_query!(name, value)
303
+ query[name] = value
304
+ end
305
+ sort = sort.to_s if sort.is_a?(Symbol)
306
+ unless q.nil? || (q.is_a?(String) && q.length.between?(1, Q_MAX))
307
+ Checks.usage("Query parameter q must be 1 to #{Q_MAX} characters.")
308
+ end
309
+ unless sort.nil? || SORTS.include?(sort)
310
+ Checks.usage("Query parameter sort must be newest, oldest or title, got #{printable(sort.to_s, SORT_ECHO)}.")
311
+ end
312
+ query["q"] = q unless q.nil?
313
+ query["sort"] = sort unless sort.nil?
314
+ path = "/v1/collections/#{segment(collection_id)}/items"
315
+ call("GET", query.empty? ? path : "#{path}?#{query.map { |k, v| "#{k}=#{URI.encode_uri_component(v.to_s)}" }.join('&')}")
316
+ end
317
+
318
+ # `DELETE /v1/collections/{id}/items/{item_id}`.
319
+ def delete_item(collection_id, item_id)
320
+ call("DELETE", "/v1/collections/#{segment(collection_id)}/items/#{segment(item_id)}")
321
+ end
322
+
323
+ private
324
+
325
+ def call(method, path, body = nil, key: nil, timeout: nil)
326
+ return @client.__send__(:request, method, path, body, timeout: timeout).first if key.nil?
327
+
328
+ parsed, = @client.__send__(:request, method, path, body, policy: :idempotent, timeout: timeout,
329
+ extra_headers: { "Idempotency-Key" => key })
330
+ parsed
331
+ end
332
+
333
+ # Whether wait_until_ready may stop: an item ready or failed, or a
334
+ # knowledge base with nothing queued or processing.
335
+ def settled?(found, item_id)
336
+ return false unless found.is_a?(Hash)
337
+ return %w[ready failed].include?(found["status"]) unless item_id.nil?
338
+
339
+ counts = found["status_counts"].is_a?(Hash) ? found["status_counts"] : {}
340
+ %w[queued processing].all? { |k| counts[k].nil? || counts[k] == 0 }
341
+ end
342
+
343
+ # An item write: the fields checked, the document read (and uploaded when
344
+ # it is not sent inline), then the call with its own Idempotency-Key.
345
+ def document_write(method, path, document, fields, wait)
346
+ raise ArgumentError, "document must be a DecisionNode::Document, got #{document.class}" unless document.is_a?(Document)
347
+
348
+ item = document_entries(fields.slice("reference", "pin_rank", "label", "metadata", "pinned"))
349
+ title = fields.key?("title") ? document_entries(fields.slice("title")) : nil
350
+ entries = []
351
+ if document.text?
352
+ entries.concat(title || [missing_title])
353
+ entries << Checks.entry("string_too_short", %w[body text], "The text is empty", "") if document.text.empty?
354
+ unless document.format.nil? || Document::FORMATS.include?(document.format)
355
+ entries << Checks.entry("literal_error", %w[body format], "Input should be 'markdown' or 'text'",
356
+ echoed(document.format))
357
+ end
358
+ raise Checks.invalid(entries + item) unless entries.empty? && item.empty?
359
+ end
360
+ # A file's own faults (its type, size) come before the fields'.
361
+ loaded = document.load
362
+ inline = inline_text(loaded)
363
+ unless document.text?
364
+ entries.concat(title || (inline && loaded.filename.nil? ? [missing_title] : []))
365
+ raise Checks.invalid(entries + item) unless entries.empty? && item.empty?
366
+ end
367
+
368
+ path = "#{path}?wait=true" if wait == true
369
+ timeout = @client.__send__(:write_timeout, wait == true)
370
+ return call(method, path, inline_body(loaded, inline, fields), key: SecureRandom.uuid, timeout: timeout) if inline
371
+
372
+ 2.times do |attempt|
373
+ upload_id = loaded.kind == :upload ? loaded.upload_id : @client.__send__(:upload_knowledge, [loaded]).first
374
+ body = { "upload_id" => upload_id }
375
+ body["title"] = fields["title"] if fields.key?("title")
376
+ body.merge!(fields.except("title"))
377
+ begin
378
+ # A resend after a stale upload names a new upload, so it is a new
379
+ # request with its own key.
380
+ return call(method, path, body, key: SecureRandom.uuid, timeout: timeout)
381
+ rescue BadRequestError => e
382
+ raise unless attempt.zero? && loaded.kind == :file && Uploads::STALE_UPLOAD.include?(e.error_type)
383
+ end
384
+ end
385
+ end
386
+
387
+ # A text, or a text or Markdown file whose bytes total under the client's
388
+ # inline threshold and read as UTF-8 without a NUL: the text and its
389
+ # format. nil for anything to upload.
390
+ def inline_text(loaded)
391
+ return [loaded.text, loaded.format] if loaded.kind == :text
392
+ return nil unless loaded.kind == :file && %w[text/plain text/markdown].include?(loaded.content_type)
393
+ return nil unless loaded.data.bytesize < @client.inline_threshold
394
+
395
+ text = loaded.data.dup.force_encoding(Encoding::UTF_8)
396
+ return nil unless text.valid_encoding? && !text.include?("\0")
397
+
398
+ [text, loaded.content_type == "text/markdown" ? "markdown" : "text"]
399
+ end
400
+
401
+ # The body of a document sent inline: its title (a file's name without its
402
+ # extension when none is given), its text and format, then the fields.
403
+ def inline_body(loaded, inline, fields)
404
+ text, format = inline
405
+ body = { "title" => fields.fetch("title") { Document.stem(loaded.filename) }, "text" => text }
406
+ body["format"] = format unless format.nil?
407
+ body.merge(fields.except("title"))
408
+ end
409
+
410
+ def missing_title
411
+ Checks.entry("missing", %w[body title], "Field required", nil)
412
+ end
413
+
414
+ # The 422 entries of a document's fields, in the order given: the title
415
+ # (1 to 200 characters once trimmed), `pinned` (true or false) and
416
+ # `pin_rank` (an Integer from 0 to 1,000), and the reference, the label and
417
+ # the metadata as #enroll checks them.
418
+ def document_entries(fields)
419
+ entries = []
420
+ fields.each do |name, value|
421
+ case name
422
+ when "title"
423
+ next if value.is_a?(String) && value.strip.length.between?(1, TITLE_MAX)
424
+
425
+ entries << Checks.entry("string_too_long", %w[body title], "A title is 1 to #{TITLE_MAX} characters", echoed(value))
426
+ when "reference"
427
+ entries.concat(string_entries(%w[body reference], value, 1, REFERENCE_MAX, REFERENCE_RULE))
428
+ when "pinned"
429
+ next if [true, false].include?(value)
430
+
431
+ entries << Checks.entry("bool_type", %w[body pinned], "Input should be a valid boolean", echoed(value))
432
+ when "pin_rank"
433
+ next if value.is_a?(Integer) && value.between?(0, PIN_RANK_MAX)
434
+
435
+ entries << Checks.entry("int_range", %w[body pin_rank], "Input should be an integer from 0 to #{PIN_RANK_MAX}",
436
+ echoed(value))
437
+ when "label"
438
+ entries.concat(string_entries(%w[body label], value, 1, LABEL_MAX)) unless value.nil?
439
+ when "metadata"
440
+ entries.concat(metadata_entries(value))
441
+ end
442
+ end
443
+ entries
444
+ end
445
+
446
+ def segment(id)
447
+ raise ArgumentError, "an id is required" if id.nil? || id.to_s.empty?
448
+
449
+ URI.encode_uri_component(id.to_s)
450
+ end
451
+
452
+ # An item's metadata's 422 entries, as the API answers them: not a Hash,
453
+ # more than 20 keys (that alone, before any key is read), a key not 1 to 40
454
+ # of a-z, 0-9 and _, a text over 500 characters, a value that is not a
455
+ # text, a number, true or false; in key order. A text's control or
456
+ # invisible characters, a number's range and the 4 KB limit are the API's.
457
+ def metadata_entries(metadata)
458
+ loc = %w[body metadata]
459
+ return [Checks.entry("value_error", loc, "Metadata is an object of keys and values.", echoed(metadata))] unless metadata.is_a?(Hash)
460
+ if metadata.size > METADATA_KEYS
461
+ return [Checks.entry("value_error", loc, "Metadata holds at most #{METADATA_KEYS} keys.", nil)]
462
+ end
463
+
464
+ metadata.each_with_object([]) do |(key, value), out|
465
+ key = key.to_s if key.is_a?(Symbol)
466
+ if !key.is_a?(String) || !key.match?(METADATA_KEY_RULE)
467
+ out << Checks.entry("value_error", loc, "A metadata key is 1 to #{METADATA_KEY_CHARS} characters of a-z, 0-9 and _: \"#{shown_key(key.to_s)}\".", nil)
468
+ elsif value.is_a?(String)
469
+ if value.length > METADATA_TEXT_CHARS
470
+ out << Checks.entry("value_error", loc + [key], "A metadata text is at most #{METADATA_TEXT_CHARS} characters.", echoed(value))
471
+ end
472
+ elsif !(value.is_a?(Integer) || value.is_a?(Float) || value == true || value == false)
473
+ out << Checks.entry("value_error", loc + [key], "A metadata value is a text, a number or true or false.", echoed(value))
474
+ end
475
+ end
476
+ end
477
+
478
+ # A metadata key as the API's message shows it: its first 40 characters,
479
+ # each control character as ?, then "..." when it was longer.
480
+ def shown_key(key)
481
+ shown = key.each_char.first(METADATA_KEY_CHARS).map { |c| c.match?(/\p{Cc}/) ? "?" : c }.join
482
+ key.length > METADATA_KEY_CHARS ? "#{shown}..." : shown
483
+ end
484
+
485
+ # A `reference` or `reference_prefix` of the items list, checked as the
486
+ # API checks it.
487
+ def reference_query!(name, value)
488
+ return if value.is_a?(String) && value.match?(REFERENCE_RULE)
489
+
490
+ Checks.usage("Query parameter #{name} must be 1 to 64 characters of A-Z, a-z, 0-9, _, ., : and -, " \
491
+ "got #{printable(value.to_s, REFERENCE_MAX)}.")
492
+ end
493
+
494
+ # A caller's value as an API message echoes it: its first `most`
495
+ # characters, any but printable ASCII as \u{hex}, then "..." when longer.
496
+ def printable(value, most)
497
+ shown = value.each_char.first(most).map { |c| c.ord.between?(0x20, 0x7E) ? c : "\\u{#{c.ord.to_s(16)}}" }.join
498
+ value.length > most ? "#{shown}..." : shown
499
+ end
500
+
501
+ # How a collections 422 entry echoes an input, as the API does: a String
502
+ # cut to its first 64 characters, with "..." only when it was longer; an
503
+ # object or an array never (nil); anything else as it is.
504
+ def echoed(value)
505
+ case value
506
+ when String then value.length > ECHO_CHARS ? "#{value[0, ECHO_CHARS]}..." : value
507
+ when Hash, Array then nil
508
+ else value
509
+ end
510
+ end
511
+
512
+ # A string field's 422 entry, as the API answers it: missing, not a
513
+ # String, too short, too long, or (with a pattern) not matching it. The
514
+ # length is checked before the pattern, so an empty value is too short.
515
+ def string_entries(loc, value, least, most, rule = nil, required: least.positive?)
516
+ return required ? [Checks.entry("missing", loc, "Field required", nil)] : [] if value.nil?
517
+ return [Checks.entry("string_type", loc, "Input should be a valid string", echoed(value))] unless value.is_a?(String)
518
+
519
+ if value.length < least
520
+ chars = least == 1 ? "character" : "characters"
521
+ return [Checks.entry("string_too_short", loc, "String should have at least #{least} #{chars}", value,
522
+ { "min_length" => least })]
523
+ end
524
+ if value.length > most
525
+ return [Checks.entry("string_too_long", loc, "String should have at most #{most} characters", echoed(value),
526
+ { "max_length" => most })]
527
+ end
528
+ if rule && !value.match?(rule)
529
+ return [Checks.entry("string_pattern_mismatch", loc, "String should match pattern '#{REFERENCE_PATTERN}'", value,
530
+ { "pattern" => REFERENCE_PATTERN })]
531
+ end
532
+ []
533
+ end
534
+ end
535
+ end