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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +128 -0
- data/LICENSE +21 -0
- data/README.md +908 -0
- data/lib/decisionnode/batches.rb +246 -0
- data/lib/decisionnode/boosters.rb +692 -0
- data/lib/decisionnode/checks.rb +722 -0
- data/lib/decisionnode/client.rb +1100 -0
- data/lib/decisionnode/collections.rb +535 -0
- data/lib/decisionnode/document.rb +179 -0
- data/lib/decisionnode/errors.rb +138 -0
- data/lib/decisionnode/image.rb +194 -0
- data/lib/decisionnode/image_header.rb +121 -0
- data/lib/decisionnode/keys.rb +383 -0
- data/lib/decisionnode/questions.rb +135 -0
- data/lib/decisionnode/results.rb +539 -0
- data/lib/decisionnode/session.rb +286 -0
- data/lib/decisionnode/session_stream.rb +255 -0
- data/lib/decisionnode/supervisor.rb +107 -0
- data/lib/decisionnode/transport.rb +249 -0
- data/lib/decisionnode/uploads.rb +422 -0
- data/lib/decisionnode/version.rb +9 -0
- data/lib/decisionnode/video.rb +128 -0
- data/lib/decisionnode.rb +39 -0
- metadata +85 -0
|
@@ -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
|