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,722 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "json"
5
+ require "socket"
6
+
7
+ class DecisionNode
8
+ # The API's fixed, documented request rules, checked before any request so
9
+ # the server never sees a request the client could have refused. A fault
10
+ # raises what the server would answer: the same class, the same
11
+ # `error_type`, the documented message, and for a 422 a ValidationError
12
+ # whose `entries` are the server's list. A local error has no status and no
13
+ # request id. Checks run in the server's order: the nesting limit, every 422
14
+ # entry, the first 400 in question order, then the images.
15
+ #
16
+ # A limit that only `GET /v1/models` states is checked against the value the
17
+ # client holds from an earlier `models` call, and skipped when it holds none.
18
+ # Token counts and anything that depends on server state stay the server's.
19
+ module Checks
20
+ MAX_IMAGES = 16
21
+ # An image's id, counted in characters (code points), never bytes.
22
+ MAX_IMAGE_ID_CHARS = 64
23
+ MAX_VIDEOS = 4
24
+ MAX_FPS = 4
25
+ MAX_CHOICE_OPTIONS = 255
26
+ MAX_SCORE_LEVELS = 10
27
+ MAX_GRID_VALUES = 4_096
28
+ MAX_NESTING = 128
29
+ # The API's request body limit, 25 MiB, counted on the encoded body.
30
+ MAX_BODY_BYTES = 26_214_400
31
+ BODY_TOO_LARGE = "The request body is over 26,214,400 bytes (25 MiB). Send large images as uploads " \
32
+ "(POST /v1/uploads) and name them by upload_id."
33
+ private_constant :MAX_IMAGE_ID_CHARS, :MAX_BODY_BYTES, :BODY_TOO_LARGE
34
+ MAX_WINDOW = 64
35
+ MAX_TTL_SECONDS = 3_600
36
+ MAX_SEQ = (2**53) - 1
37
+ TWO_53 = 2**53
38
+ MAX_NUMBER_DECIMALS = 6
39
+ LIST_LIMIT_MAX = 100
40
+ CUSTOM_ID_CHARS = 512
41
+ MEDIA_TYPES = %w[image/jpeg image/png image/webp].freeze
42
+ UNITS = %w[pixels normalized].freeze
43
+ # The places a point or box question may ask for with `max`.
44
+ PLACES_MIN = 2
45
+ PLACES_MAX = 16
46
+ GRID_DEFAULTS = { "min" => 0, "max" => 255, "step" => 1 }.freeze
47
+
48
+ # Literal addresses an image URL may not name (IPv4-mapped and NAT64
49
+ # addresses are judged by the IPv4 address they embed).
50
+ BLOCKED_NETWORKS = %w[
51
+ 0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12
52
+ 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 192.168.0.0/16 198.18.0.0/15
53
+ 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 240.0.0.0/4 255.255.255.255/32
54
+ ::/128 ::1/128 fc00::/7 fe80::/10 ff00::/8 2001:db8::/32 fd00:ec2::254/128
55
+ ].map { |net| IPAddr.new(net) }.freeze
56
+ NAT64 = IPAddr.new("64:ff9b::/96")
57
+
58
+ module_function
59
+
60
+ # The question types the API takes, the older name of truth included,
61
+ # which a raw question may still use.
62
+ TRUTH_OLD_TYPE = "noul"
63
+ QUESTION_TYPES = ["truth", TRUTH_OLD_TYPE, "choice", "score", "number", "point", "box"].freeze
64
+
65
+ # ---- requests
66
+
67
+ # Checks a request body (String keys) for `/v1/decide` (profile :decide),
68
+ # a session's opening (:session) or a batch line. A point or box question is checked against the ids of the body's
69
+ # `images`; a session's images come with its frames, so there they are
70
+ # the server's to judge.
71
+ def request!(body, profile: :decide, limits: {}, entries: [], counts: [])
72
+ nesting!(body)
73
+ entries = entries.dup
74
+ deferred = []
75
+ state = body["state"]
76
+ entries.concat(sdl_entries(%w[body state], state)) unless state.nil? || sdl?(state)
77
+ image_ids = profile == :session ? nil : Array(body["images"]).map { |img| img.is_a?(Hash) ? img["id"] || img[:id] : nil }
78
+ (body["questions"] || {}).each { |id, q| question(id, q, entries, deferred, limits, image_ids) }
79
+ entries.concat(counts)
80
+ raise invalid(entries) unless entries.empty?
81
+ raise deferred.first unless deferred.empty?
82
+
83
+ nil
84
+ end
85
+
86
+ # Checks a question map alone.
87
+ def questions!(questions, profile: :decide, limits: {})
88
+ request!({ "questions" => questions }, profile: profile, limits: limits)
89
+ end
90
+
91
+ # How many objects and arrays sit inside each other, the outermost included.
92
+ def nesting_depth(value)
93
+ deepest = 0
94
+ stack = [[value, 1]]
95
+ until stack.empty?
96
+ node, depth = stack.pop
97
+ children = case node
98
+ when Hash then node.values
99
+ when Array then node
100
+ else next
101
+ end
102
+ deepest = depth if depth > deepest
103
+ children.each { |child| stack << [child, depth + 1] if child.is_a?(Hash) || child.is_a?(Array) }
104
+ end
105
+ deepest
106
+ end
107
+
108
+ def nesting!(body)
109
+ depth = nesting_depth(body)
110
+ return if depth <= MAX_NESTING
111
+
112
+ raise BadRequestError.new("Invalid request.", error_type: "api_usage_error",
113
+ reason: "the request nests #{depth} objects and arrays inside each other; " \
114
+ "the limit is #{MAX_NESTING}")
115
+ end
116
+
117
+ # The encoded body, exactly as it would be sent, against the API's body
118
+ # limit: RequestTooLargeError when it is over.
119
+ def body_size!(data)
120
+ size = data.bytesize
121
+ return if size <= MAX_BODY_BYTES
122
+
123
+ raise RequestTooLargeError.new(BODY_TOO_LARGE, error_type: "request_too_large_error",
124
+ reason: "the body is #{size} bytes; the limit is #{MAX_BODY_BYTES}")
125
+ end
126
+
127
+ # ---- images
128
+
129
+ # The 422 entries of a request with more images or videos than the API
130
+ # takes, images first: `too_long` on the list, whose `input` is the list
131
+ # in its wire form (a local image without its bytes). They join the
132
+ # request's other 422 entries, after the state's and the questions'.
133
+ def count_entries(images, videos, limits: {})
134
+ out = []
135
+ most = held(limits, "max_images") || MAX_IMAGES
136
+ out << too_long("images", images.map { |img| image_listed(img) }, most) if images.size > most
137
+ out << too_long("videos", videos.map { |v| v.is_a?(Video) ? v.to_h : v }, MAX_VIDEOS) if videos.size > MAX_VIDEOS
138
+ out
139
+ end
140
+
141
+ def too_long(name, input, most)
142
+ entry("too_long", ["body", name], "List should have at most #{most} items after validation, not #{input.size}", input,
143
+ { "field_type" => "List", "max_length" => most, "actual_length" => input.size })
144
+ end
145
+
146
+ # An image as a count entry lists it: a local image by its id and media
147
+ # type, its bytes left out.
148
+ def image_listed(img)
149
+ return img unless img.is_a?(Image)
150
+
151
+ img.local? ? { "id" => img.id, "media_type" => img.media_type } : img.to_h
152
+ end
153
+
154
+ # Checks a request's images, once their count is known to be within the
155
+ # limit: ids, URLs, upload references and each local image's bytes.
156
+ # id_length is the 64-character id rule, for a body that carries the ids;
157
+ # a session frame's image is sent without its id, so a frame skips it.
158
+ def images!(images, limits: {}, id_length: true)
159
+ return nil if images.nil? || images.empty?
160
+
161
+ biggest = held(limits, "max_image_bytes") || Image::MAX_BYTES
162
+ seen = {}
163
+ images.each_with_index do |img, i|
164
+ id = img.is_a?(Image) ? img.id : (img["id"] || img[:id])
165
+ usage("Image #{i} needs an id.") unless id.is_a?(String) && !id.empty?
166
+ if id_length && code_points(id) > MAX_IMAGE_ID_CHARS
167
+ usage("Image #{i}: its id is longer than #{MAX_IMAGE_ID_CHARS} characters.")
168
+ end
169
+ usage("Duplicate image id: #{id}") if seen.key?(id)
170
+
171
+ seen[id] = true
172
+ image!(img, biggest) if img.is_a?(Image)
173
+ end
174
+ nil
175
+ end
176
+
177
+ # A string's length in code points, whatever its encoding says; bytes
178
+ # without an encoding are read as UTF-8.
179
+ def code_points(text)
180
+ text = text.dup.force_encoding(Encoding::UTF_8) if text.encoding == Encoding::BINARY
181
+ text.length
182
+ end
183
+
184
+ def image!(img, biggest)
185
+ if img.url
186
+ image_url!(img.id, img.url)
187
+ elsif img.upload_id
188
+ return if img.media_type.nil? || MEDIA_TYPES.include?(img.media_type)
189
+
190
+ usage("Image #{img.id}: media_type must be image/jpeg, image/png or image/webp.")
191
+ else
192
+ img.validate!(max_bytes: biggest)
193
+ end
194
+ end
195
+
196
+ # The URL rules that need no network: https, then at most 2,048
197
+ # characters, no user information, port 443, a host, and no literal
198
+ # address in a blocked range. A DNS name is the server's to resolve.
199
+ def image_url!(id, url)
200
+ usage("Image #{id}: only https URLs are accepted.") unless url.is_a?(String) && url.start_with?("https://")
201
+
202
+ reason = url_refusal(url)
203
+ return if reason.nil?
204
+
205
+ raise BadRequestError.new("Image '#{id}': #{reason}.", error_type: "image_url_refused")
206
+ end
207
+
208
+ def url_refusal(url)
209
+ return "the URL is #{url.length} characters, over the limit of #{Image::MAX_URL_CHARS}" if url.length > Image::MAX_URL_CHARS
210
+
211
+ authority = url.delete_prefix("https://")[%r{\A[^/?#]*}]
212
+ return "the URL carries user information" if authority.include?("@")
213
+
214
+ host, port = split_authority(authority)
215
+ return "the URL uses a port other than 443" unless port.nil? || port == "443"
216
+ return "the URL has no host" if host.empty?
217
+ return "the URL resolves to a private address" if blocked_address?(host)
218
+
219
+ nil
220
+ end
221
+
222
+ # Host and port of a URL authority; the port is nil when none is written.
223
+ def split_authority(authority)
224
+ if authority.start_with?("[")
225
+ close = authority.index("]")
226
+ return [authority, "-1"] if close.nil?
227
+
228
+ rest = authority[(close + 1)..]
229
+ port = if rest.empty? then nil
230
+ elsif rest.start_with?(":") then rest[1..]
231
+ else "-1"
232
+ end
233
+ return [authority[1...close], port]
234
+ end
235
+ host, sep, port = authority.partition(":")
236
+ [host, sep.empty? ? nil : port]
237
+ end
238
+
239
+ def blocked_address?(host)
240
+ addr = IPAddr.new(host)
241
+ if addr.ipv6?
242
+ addr = addr.native if addr.ipv4_mapped?
243
+ addr = IPAddr.new(addr.to_i & 0xFFFFFFFF, Socket::AF_INET) if addr.ipv6? && NAT64.include?(addr)
244
+ end
245
+ BLOCKED_NETWORKS.any? { |net| net.family == addr.family && net.include?(addr) }
246
+ rescue IPAddr::Error, ArgumentError
247
+ false # a DNS name: resolved and judged by the server
248
+ end
249
+
250
+ # An image limit the client holds from /v1/models: on the model's entry,
251
+ # or inside its `images` object.
252
+ def held(limits, name)
253
+ value = limits[name]
254
+ value = limits["images"][name] if value.nil? && limits["images"].is_a?(Hash)
255
+ value.is_a?(Integer) && value.positive? ? value : nil
256
+ end
257
+
258
+ # ---- videos
259
+
260
+ # Checks a request's videos in the API's order, after its images, once
261
+ # their count is known to be within the limit: each video's id (unique
262
+ # across images and videos), its
263
+ # frames (each time a number of at least 0, larger than the one before,
264
+ # and a JPEG, PNG or WebP media type), `fps`, `start` and `end`. The
265
+ # frame counts, a frame's bytes and the window holding a frame are the
266
+ # server's to judge.
267
+ def videos!(videos, image_ids: [])
268
+ return nil if videos.nil? || videos.empty?
269
+
270
+ seen = image_ids.to_h { |id| [id, true] }
271
+ videos.each_with_index do |video, i|
272
+ id = video_field(video, "id")
273
+ usage("Video #{i} needs an id.") unless id.is_a?(String) && !id.empty?
274
+ usage("Video id #{py_repr(id)} is used twice (ids are unique across images and videos).") if seen.key?(id)
275
+
276
+ seen[id] = true
277
+ video!(id, video)
278
+ end
279
+ nil
280
+ end
281
+
282
+ def video!(id, video)
283
+ frames = video_field(video, "frames")
284
+ video_usage(id, "frames must be a non-empty list of frames") unless frames.is_a?(Array) && !frames.empty?
285
+ before = nil
286
+ frames.each_with_index do |frame, n|
287
+ video_usage(id, "frame #{n} must be an object with t, media_type and data") unless frame.is_a?(VideoFrame) || frame.is_a?(Hash)
288
+ t = video_field(frame, "t")
289
+ video_usage(id, "frame #{n}: t must be a number of seconds, at least 0") unless seconds?(t) && t >= 0
290
+ video_usage(id, "frame #{n}: t must be larger than the frame before it (#{g(before)})") if before && t <= before
291
+ before = t
292
+ video_usage(id, "frame #{n}: data must be base64 text") unless frame.is_a?(VideoFrame) || video_field(frame, "data").is_a?(String)
293
+ unless MEDIA_TYPES.include?(video_field(frame, "media_type"))
294
+ video_usage(id, "frame #{n}: media_type must be one of #{MEDIA_TYPES.join(', ')}")
295
+ end
296
+ end
297
+ fps = video_field(video, "fps")
298
+ video_usage(id, "fps must be a number above 0 and at most #{g(MAX_FPS)}") unless fps.nil? || (seconds?(fps) && fps.positive? && fps <= MAX_FPS)
299
+ %w[start end].each do |name|
300
+ value = video_field(video, name)
301
+ video_usage(id, "#{name} must be a number of seconds, at least 0") unless value.nil? || (seconds?(value) && value >= 0)
302
+ end
303
+ last = video_field(video, "end")
304
+ video_usage(id, "end must be after start") if !last.nil? && last <= (video_field(video, "start") || 0)
305
+ end
306
+
307
+ # A field of a Video or VideoFrame, or of its wire-form Hash (String or Symbol keys).
308
+ def video_field(item, name)
309
+ case item
310
+ when Video, VideoFrame then item.public_send(name)
311
+ when Hash then item.key?(name) ? item[name] : item[name.to_sym]
312
+ end
313
+ end
314
+
315
+ # A finite JSON number, never a boolean.
316
+ def seconds?(value)
317
+ (value.is_a?(Integer) || value.is_a?(Float)) && value.finite?
318
+ end
319
+
320
+ # A number as the API writes it in a message (printf's %g): 1.5, 4, 1e+06.
321
+ def g(value)
322
+ format("%g", value)
323
+ end
324
+
325
+ # A string quoted as the API quotes an id in a message: 'dock', or
326
+ # "it's" when the id holds a single quote and no double quote.
327
+ def py_repr(text)
328
+ quote = text.include?("'") && !text.include?('"') ? '"' : "'"
329
+ body = text.gsub("\\") { "\\\\" }.gsub(quote) { "\\#{quote}" }
330
+ body = body.gsub(/[\x00-\x1f\x7f]/) { |c| REPR_ESCAPES.fetch(c) { format("\\x%02x", c.ord) } }
331
+ "#{quote}#{body}#{quote}"
332
+ end
333
+
334
+ REPR_ESCAPES = { "\n" => "\\n", "\r" => "\\r", "\t" => "\\t" }.freeze
335
+
336
+ def video_usage(id, problem)
337
+ usage("Video #{py_repr(id)}: #{problem}.")
338
+ end
339
+
340
+ # ---- sessions
341
+
342
+ # `POST /v1/sessions`: window 1 to 64 and ttl_seconds 1 to 3,600 (a held
343
+ # sessions.max_window or max_ttl_seconds replaces the bound), the types of
344
+ # instructions and state, every question rule, and a model the client
345
+ # knows serves no sessions.
346
+ def session_open!(body, limits: {})
347
+ nesting!(body)
348
+ sessions = limits["sessions"].is_a?(Hash) ? limits["sessions"] : {}
349
+ entries = []
350
+ %w[instructions state].each do |name|
351
+ value = body[name]
352
+ entries.concat(sdl_entries(["body", name], value)) unless value.nil? || sdl?(value)
353
+ end
354
+ bounds = { "window" => sessions["max_window"] || MAX_WINDOW, "ttl_seconds" => sessions["max_ttl_seconds"] || MAX_TTL_SECONDS }
355
+ bounds.each do |name, most|
356
+ fault = bounded_int(name, body[name], most)
357
+ entries << fault if fault
358
+ end
359
+ request!({ "questions" => body["questions"] }, profile: :session, limits: limits, entries: entries)
360
+ usage("Model #{limits['model']} does not serve sessions.") if sessions["enabled"] == false
361
+ nil
362
+ end
363
+
364
+ def bounded_int(name, value, most)
365
+ return nil if value.nil?
366
+ return entry("int_type", ["body", name], "Input should be a valid integer", value) unless value.is_a?(Integer)
367
+ return entry("greater_than_equal", ["body", name], "Input should be greater than or equal to 1", value, { "ge" => 1 }) if value < 1
368
+ return nil if value <= most
369
+
370
+ entry("less_than_equal", ["body", name], "Input should be less than or equal to #{most}", value, { "le" => most })
371
+ end
372
+
373
+ # One frame, before it is written: the fault the server's frame error
374
+ # would name, raised as BadRequestError with `api_usage_error`.
375
+ def frame!(frame:, image:, questions:, reset:, seq:, known_questions:, limits: {})
376
+ usage("seq: must be an integer from 0 to #{MAX_SEQ}.") unless seq.nil? || (seq.is_a?(Integer) && seq.between?(0, MAX_SEQ))
377
+ usage("frame: a frame needs a frame value or an image.") if frame.nil? && image.nil?
378
+ usage("frame: must be a string, an object or an array, not empty.") if !frame.nil? && (!sdl?(frame) || frame == "")
379
+ frame_questions!(questions, known_questions) unless questions.nil?
380
+ usage("reset: must be true or false.") unless [nil, true, false].include?(reset)
381
+ if !frame.nil? && nesting_depth(frame) + 1 > MAX_NESTING
382
+ usage("The frame nests more than #{MAX_NESTING} objects and arrays inside each other.")
383
+ end
384
+ return unless image.is_a?(Image)
385
+
386
+ usage("image: a frame takes bytes or an upload, not a url.") if image.url
387
+ images!([image], limits: limits, id_length: false)
388
+ end
389
+
390
+ def frame_questions!(questions, known)
391
+ list = questions.to_a
392
+ usage("questions: must be a non-empty list of the session's question ids.") if list.empty?
393
+ return unless known.is_a?(Array)
394
+
395
+ unknown = list.find { |id| !known.include?(id) }
396
+ return if unknown.nil?
397
+
398
+ usage("questions: unknown question id #{unknown.is_a?(String) ? "'#{unknown}'" : JSON.generate(unknown, max_nesting: false)}")
399
+ end
400
+
401
+ # ---- batches
402
+
403
+ # The custom_id rules of one create or add call (lines numbered from 1),
404
+ # an add with no requests, and the held requests_per_batch and
405
+ # max_custom_id_chars.
406
+ def lines!(requests, allow_empty:, limits: {})
407
+ usage("The call holds no requests.") if requests.empty? && !allow_empty
408
+ batch = limits["batch"].is_a?(Hash) ? limits["batch"] : {}
409
+ most = batch["requests_per_batch"]
410
+ usage("The batch would hold #{requests.size} requests. The limit is #{most}.") if most && requests.size > most
411
+ longest = batch["max_custom_id_chars"]
412
+ seen = {}
413
+ requests.each.with_index(1) do |req, n|
414
+ raise ArgumentError, "batch request #{n} must be a Hash of decide's arguments and a custom_id" unless req.is_a?(Hash)
415
+
416
+ id = req.key?("custom_id") ? req["custom_id"] : req[:custom_id]
417
+ custom_id!(id, n, longest)
418
+ if seen.key?(id)
419
+ shown = JSON.generate(id, max_nesting: false)
420
+ usage("Line #{n}: custom_id #{shown.length <= 64 ? shown : "#{shown[0, 64]}..."} is already in the batch.")
421
+ end
422
+ seen[id] = true
423
+ end
424
+ nil
425
+ end
426
+
427
+ def custom_id!(id, line, longest)
428
+ usage("Line #{line} has no custom_id.") if id.nil?
429
+ return if id.is_a?(String) && !id.empty? && (longest.nil? || id.length <= longest)
430
+
431
+ usage("Line #{line}: custom_id must be a string of 1 to #{longest || CUSTOM_ID_CHARS} characters.")
432
+ end
433
+
434
+ # `GET /v1/batches`: offset an integer of 0 or more, limit 1 to 100.
435
+ def list!(offset, limit)
436
+ unless offset.nil? || (offset.is_a?(Integer) && !offset.negative?)
437
+ usage("Query parameter offset must be an integer of 0 or more, got #{offset}.")
438
+ end
439
+ return if limit.nil? || (limit.is_a?(Integer) && limit.between?(1, LIST_LIMIT_MAX))
440
+
441
+ usage("Query parameter limit must be an integer from 1 to #{LIST_LIMIT_MAX}, got #{limit}.")
442
+ end
443
+
444
+ # ---- several places
445
+
446
+ # The `max` of a point or box question asking for several places: an
447
+ # Integer from 2 to 16.
448
+ def places_max(most, loc, entries)
449
+ return if most.nil?
450
+
451
+ if !most.is_a?(Integer)
452
+ entries << entry("int_type", loc + ["max"], "Input should be a valid integer", most)
453
+ elsif most < PLACES_MIN
454
+ entries << entry("greater_than_equal", loc + ["max"], "Input should be greater than or equal to #{PLACES_MIN}",
455
+ most, { "ge" => PLACES_MIN })
456
+ elsif most > PLACES_MAX
457
+ entries << entry("less_than_equal", loc + ["max"], "Input should be less than or equal to #{PLACES_MAX}",
458
+ most, { "le" => PLACES_MAX })
459
+ end
460
+ end
461
+
462
+ # ---- questions
463
+
464
+ def question(id, q, entries, deferred, limits, image_ids)
465
+ base = ["body", "questions", id]
466
+ unless q.is_a?(Hash)
467
+ entries << entry("model_type", base, "Input should be a valid dictionary or object", q)
468
+ return
469
+ end
470
+ if q["type"].nil?
471
+ entries << entry("missing", base + ["type"], "Field required", q)
472
+ return
473
+ end
474
+ type = q["type"]
475
+ unless QUESTION_TYPES.include?(type)
476
+ deferred << BadRequestError.new("Invalid request.", error_type: "api_usage_error",
477
+ reason: "question '#{id}': unknown type #{JSON.generate(type, max_nesting: false)}")
478
+ return
479
+ end
480
+ loc = base + [type]
481
+ instructions = q["instructions"]
482
+ entries.concat(sdl_entries(loc + ["instructions"], instructions)) unless instructions.nil? || sdl?(instructions)
483
+ case type
484
+ when "number" then number(id, q, loc, entries, deferred, limits)
485
+ when "choice" then choice(id, q, loc, entries, deferred, limits)
486
+ when "score" then score(q, loc, entries, deferred, limits)
487
+ when "point", "box" then place(id, q, loc, entries, deferred, image_ids)
488
+ else truth(id, q, loc, entries, deferred)
489
+ end
490
+ end
491
+
492
+ # A point or box question: the image it looks in, its units, and its
493
+ # instructions. `image_ids` are the request's image ids, nil when the
494
+ # images are not known here.
495
+ def place(id, q, loc, entries, deferred, image_ids)
496
+ image = q["image"]
497
+ unless image_ids.nil?
498
+ if image_ids.empty?
499
+ entries << entry("image_required", loc, "A #{q['type']} question needs an image in the request", q)
500
+ elsif image.nil?
501
+ entries << entry("missing", loc + ["image"], "Field required", q) if image_ids.size >= 2
502
+ elsif !image.is_a?(String)
503
+ entries << entry("string_type", loc + ["image"], "Input should be a valid string", image)
504
+ elsif !image_ids.include?(image)
505
+ entries << entry("unknown_image", loc + ["image"], "Input should be the id of an image of the request", image)
506
+ end
507
+ end
508
+ units = q["units"]
509
+ unless units.nil? || UNITS.include?(units)
510
+ entries << entry("literal_error", loc + ["units"], "Input should be 'pixels' or 'normalized'", units,
511
+ { "expected" => "'pixels' or 'normalized'" })
512
+ end
513
+ places_max(q["max"], loc, entries)
514
+ instructions = q["instructions"]
515
+ deferred << plain("#{q['type'].capitalize} question must have instructions: #{id}") if instructions.nil? || instructions == ""
516
+ end
517
+
518
+ def choice(id, q, loc, entries, deferred, limits)
519
+ criteria = q["criteria"]
520
+ most = limits["max_choice_options"] || MAX_CHOICE_OPTIONS
521
+ if criteria.nil?
522
+ entries << entry("missing", loc + ["criteria"], "Field required", q)
523
+ elsif !criteria.is_a?(Hash)
524
+ entries << entry("dict_type", loc + ["criteria"], "Input should be a valid dictionary", criteria)
525
+ else
526
+ criteria.each do |name, desc|
527
+ entries.concat(sdl_entries(loc + ["criteria", name.to_s], desc)) unless desc.nil? || sdl?(desc)
528
+ end
529
+ if criteria.empty?
530
+ deferred << plain("Choice question must have at least one choice: #{id}")
531
+ elsif criteria.size > most
532
+ deferred << plain("Too many choices. Must have at most #{most} choices.")
533
+ end
534
+ end
535
+ end
536
+
537
+ def score(q, loc, entries, deferred, limits)
538
+ criteria = q["criteria"]
539
+ most = limits["max_score_levels"] || MAX_SCORE_LEVELS
540
+ if criteria.nil?
541
+ entries << entry("missing", loc + ["criteria"], "Field required", q)
542
+ return
543
+ end
544
+ levels = score_levels(criteria)
545
+ if levels.nil?
546
+ entries << entry("list_type", loc + ["criteria"], "Input should be a valid list", criteria)
547
+ return
548
+ end
549
+ if levels.empty?
550
+ entries << entry("too_short", loc + ["criteria"], "List should have at least 1 item after validation, not 0", criteria)
551
+ return
552
+ end
553
+ levels.each_with_index { |level, i| entries.concat(sdl_entries(loc + ["criteria", i], level)) unless sdl?(level) }
554
+ deferred << plain("Too many score levels. Must have at most #{most} levels.") if levels.size > most
555
+ end
556
+
557
+ # The levels of a score: a list, or an object keyed "0" to "K-1".
558
+ def score_levels(criteria)
559
+ return criteria if criteria.is_a?(Array)
560
+ return nil unless criteria.is_a?(Hash)
561
+
562
+ keyed = criteria.transform_keys(&:to_s)
563
+ return nil unless keyed.keys.sort_by { |k| [k.length, k] } == Array.new(keyed.size) { |i| i.to_s }
564
+
565
+ Array.new(keyed.size) { |i| keyed[i.to_s] }
566
+ end
567
+
568
+ def truth(id, q, loc, entries, deferred)
569
+ criteria = q["criteria"]
570
+ if !criteria.nil? && !criteria.is_a?(Hash)
571
+ entries << entry("dict_type", loc + ["criteria"], "Input should be a valid dictionary", criteria)
572
+ return
573
+ end
574
+ sides = (criteria || {}).transform_keys(&:to_s)
575
+ %w[true false].each do |side|
576
+ value = sides[side]
577
+ entries.concat(sdl_entries(loc + ["criteria", side], value)) unless value.nil? || sdl?(value)
578
+ end
579
+ return unless q["instructions"].nil? && sides["true"].nil? && sides["false"].nil?
580
+
581
+ name = q["type"] == TRUTH_OLD_TYPE ? TRUTH_OLD_TYPE.capitalize : "Truth"
582
+ deferred << plain("#{name} question must have criteria or instructions: #{id}")
583
+ end
584
+
585
+ # The number grid rules: the 422 entries in the server's order, then the 400.
586
+ def number(id, q, loc, entries, deferred, limits)
587
+ if limits["number_max_grid_values"] == 0
588
+ deferred << BadRequestError.new("Model #{limits['model']} does not answer number questions.", error_type: "api_usage_error")
589
+ return
590
+ end
591
+ values = grid_values(q, loc, entries)
592
+ return if values.nil?
593
+
594
+ lo, hi, step = values
595
+ d = [places(lo), places(step)].max
596
+ return if d.positive? && beyond_decimal_bound?(q, loc, entries, lo, hi, d)
597
+
598
+ if lo > hi
599
+ max_sent = q["max"].nil? ? 255 : q["max"]
600
+ min_sent = q["min"].nil? ? 0 : q["min"]
601
+ entries << entry("less_than_equal", loc + ["min"], "Input should be less than or equal to #{JSON.generate(max_sent)}",
602
+ min_sent, { "le" => max_sent })
603
+ return
604
+ end
605
+ most = limits["number_max_grid_values"] || MAX_GRID_VALUES
606
+ scale = 10**d
607
+ size = ((hi * scale).floor - (lo * scale).to_i).div((step * scale).to_i) + 1
608
+ if size > most
609
+ entries << entry("too_long", loc, "Grid should have at most #{most} values, not #{size}", q,
610
+ { "field_type" => "Grid", "max_length" => most, "actual_length" => size })
611
+ return
612
+ end
613
+ instructions = q["instructions"]
614
+ deferred << plain("Number question must have instructions: #{id}") if instructions.nil? || instructions == ""
615
+ end
616
+
617
+ # min, max and step as exact values, or nil after adding their entries.
618
+ def grid_values(q, loc, entries)
619
+ faults = %w[min max step].filter_map { |name| q[name].nil? ? nil : number_fault(name, q[name], loc + [name]) }
620
+ unless faults.empty?
621
+ entries.concat(faults)
622
+ return nil
623
+ end
624
+
625
+ %w[min max step].map { |name| q[name].nil? ? Rational(GRID_DEFAULTS[name]) : exact(q[name]) }
626
+ end
627
+
628
+ def beyond_decimal_bound?(q, loc, entries, lo, hi, places)
629
+ bound = Rational(10**(15 - places)) - Rational(1, 10**places)
630
+ text = decimal_text(bound)
631
+ { "min" => lo, "max" => hi }.each do |name, value|
632
+ if value > bound
633
+ entries << entry("less_than_equal", loc + [name], "Input should be less than or equal to #{text}", q[name],
634
+ { "le" => json_number(bound) })
635
+ return true
636
+ elsif value < -bound
637
+ entries << entry("greater_than_equal", loc + [name], "Input should be greater than or equal to -#{text}", q[name],
638
+ { "ge" => json_number(-bound) })
639
+ return true
640
+ end
641
+ end
642
+ false
643
+ end
644
+
645
+ def number_fault(name, sent, field)
646
+ return entry("float_type", field, "Input should be a valid number", sent) unless sent.is_a?(Integer) || sent.is_a?(Float)
647
+ return entry("finite_number", field, "Input should be a finite number", sent) if sent.is_a?(Float) && !sent.finite?
648
+
649
+ value = exact(sent)
650
+ bounded = %w[min max].include?(name)
651
+ if bounded && value < -TWO_53
652
+ return entry("greater_than_equal", field, "Input should be greater than or equal to -#{TWO_53}", sent, { "ge" => -TWO_53 })
653
+ end
654
+ return entry("less_than_equal", field, "Input should be less than or equal to #{TWO_53}", sent, { "le" => TWO_53 }) if bounded && value > TWO_53
655
+ return entry("greater_than", field, "Input should be greater than 0", sent, { "gt" => 0 }) if name == "step" && value <= 0
656
+ return nil unless %w[min step].include?(name) && places(value) > MAX_NUMBER_DECIMALS
657
+
658
+ entry("decimal_max_places", field, "Decimal input should have no more than #{MAX_NUMBER_DECIMALS} decimal places",
659
+ sent, { "decimal_places" => MAX_NUMBER_DECIMALS })
660
+ end
661
+
662
+ # The exact value of a JSON number as written (a Float by its shortest form).
663
+ def exact(value)
664
+ value.is_a?(Float) ? Rational(value.to_s) : Rational(value)
665
+ end
666
+
667
+ # Decimal places of an exact value, trailing zeros dropped.
668
+ def places(value)
669
+ d = 0
670
+ d += 1 until (value * (10**d)).denominator == 1 || d > 40
671
+ d
672
+ end
673
+
674
+ def decimal_text(value)
675
+ d = places(value)
676
+ return value.to_i.to_s if d.zero?
677
+
678
+ sign = value.negative? ? "-" : ""
679
+ digits = (value.abs * (10**d)).to_i.to_s.rjust(d + 1, "0")
680
+ "#{sign}#{digits[0...-d]}.#{digits[-d..]}"
681
+ end
682
+
683
+ def json_number(value)
684
+ text = decimal_text(value)
685
+ text.include?(".") ? Float(text) : Integer(text)
686
+ end
687
+
688
+ # ---- helpers
689
+
690
+ def sdl?(value)
691
+ value.is_a?(String) || value.is_a?(Hash) || value.is_a?(Array)
692
+ end
693
+
694
+ # A value that may be a string, an object or an array: three entries.
695
+ def sdl_entries(loc, value)
696
+ [
697
+ entry("string_type", loc + ["str"], "Input should be a valid string", value),
698
+ entry("dict_type", loc + ["dict[any,any]"], "Input should be a valid dictionary", value),
699
+ entry("list_type", loc + ["list[any]"], "Input should be a valid list", value)
700
+ ]
701
+ end
702
+
703
+ def entry(type, loc, msg, input, ctx = nil)
704
+ e = { "type" => type, "loc" => loc, "msg" => msg, "input" => input }
705
+ e["ctx"] = ctx unless ctx.nil?
706
+ e
707
+ end
708
+
709
+ def invalid(entries)
710
+ ValidationError.new("", entries: entries)
711
+ end
712
+
713
+ # The API's plain-string 400: no error_type.
714
+ def plain(message)
715
+ BadRequestError.new(message)
716
+ end
717
+
718
+ def usage(message)
719
+ raise BadRequestError.new(message, error_type: "api_usage_error")
720
+ end
721
+ end
722
+ end