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,383 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ class DecisionNode
7
+ # The query rules the API checks as it reads `GET /v1/usage` and
8
+ # `GET /v1/keys`, each raised as its 400 `api_usage_error` with the API's
9
+ # message, and the query built from what is given.
10
+ module KeyFilters
11
+ GRANULARITIES = %w[none hour day month].freeze
12
+ GROUPS = %w[model channel].freeze
13
+ SCOPES = %w[full decide keys].freeze
14
+ STATUSES = %w[active expired revoked all].freeze
15
+ USAGE_LIMIT_MAX = 1_000
16
+ KEYS_LIMIT_MAX = 200
17
+ MAX_KEY_IDS = 100
18
+ KEY_ID_RULE = /\Akey_[0-9a-f]{32}\z/
19
+ LABEL_NAME_RULE = /\A[a-z0-9_.-]{1,32}\z/
20
+ LABEL_VALUE_CHARS = 64
21
+ DEVICE_ID_CHARS = 128
22
+ IDEMPOTENCY_KEY_CHARS = 255
23
+ KEY_NAME_CHARS = 48
24
+ KEY_MAX_LABELS = 16
25
+ SPEND_CAP_MAX_USD = 1_000
26
+ MAX_REVOKE_DEVICES = 1_000
27
+ ECHO_CHARS = 64
28
+ DEVICE_ID_RULE = "device_id is 1 to 128 printable ASCII characters, not starting with a space."
29
+
30
+ module_function
31
+
32
+ # A usage read's period and breakdowns, each only when given.
33
+ def period(start, finish, granularity, group_by)
34
+ Checks.usage("granularity is none, hour, day or month.") unless granularity.nil? || GRANULARITIES.include?(granularity.to_s)
35
+ Checks.usage("group_by is model or channel.") unless group_by.nil? || GROUPS.include?(group_by.to_s)
36
+
37
+ [["start", start], ["end", finish], ["granularity", granularity], ["group_by", group_by]].reject { |_, v| v.nil? }
38
+ end
39
+
40
+ # Each label as `label=name:value`, sorted by name, so a Hash sends the
41
+ # same query every time.
42
+ def labels(labels)
43
+ return [] if labels.nil?
44
+ raise ArgumentError, "labels must be a Hash of label names to values" unless labels.is_a?(Hash)
45
+
46
+ labels.map { |name, value| [name.is_a?(Symbol) ? name.to_s : name, value] }.sort_by { |name, _| name.to_s }.map do |name, value|
47
+ unless name.is_a?(String) && name.match?(LABEL_NAME_RULE) && value.is_a?(String) &&
48
+ value.length.between?(1, LABEL_VALUE_CHARS) && !value.match?(/\p{Cc}/)
49
+ Checks.usage("A label filter is name:value, as labels are written.")
50
+ end
51
+ ["label", "#{name}:#{value}"]
52
+ end
53
+ end
54
+
55
+ def device_ok?(device_id)
56
+ device_id.is_a?(String) && device_id.length.between?(1, DEVICE_ID_CHARS) &&
57
+ device_id.each_char.all? { |c| c.ord.between?(0x20, 0x7E) } && !device_id.start_with?(" ")
58
+ end
59
+
60
+ def device(device_id)
61
+ return [] if device_id.nil?
62
+
63
+ Checks.usage(DEVICE_ID_RULE) unless device_ok?(device_id)
64
+ [["device_id", device_id]]
65
+ end
66
+
67
+ def status(status)
68
+ return [] if status.nil?
69
+
70
+ Checks.usage("status is active, expired, revoked or all.") unless STATUSES.include?(status.to_s)
71
+ [["status", status]]
72
+ end
73
+
74
+ def page(limit, most, cursor)
75
+ out = []
76
+ unless limit.nil?
77
+ Checks.usage("limit is a whole number from 1 to #{most}.") unless limit.is_a?(Integer) && limit.between?(1, most)
78
+ out << ["limit", limit]
79
+ end
80
+ out << ["cursor", cursor] unless cursor.nil?
81
+ out
82
+ end
83
+
84
+ # `GET /v1/usage`'s query, checked in the API's order; the period's own
85
+ # rules and the cursor are the server's.
86
+ def usage_list(start, finish, granularity, group_by, labels, device_id, key_ids, scope, status, include_empty, limit, cursor)
87
+ query = period(start, finish, granularity, group_by) + labels(labels) + device(device_id)
88
+ Array(key_ids).each_with_index do |key_id, i|
89
+ Checks.usage("key_id is a key's id, key_ and 32 hex digits.") unless key_id.is_a?(String) && key_id.match?(KEY_ID_RULE)
90
+ Checks.usage("At most 100 key_id filters.") if i == MAX_KEY_IDS
91
+ query << ["key_id", key_id]
92
+ end
93
+ unless scope.nil?
94
+ Checks.usage("scope is full, decide or keys.") unless SCOPES.include?(scope.to_s)
95
+ query << ["scope", scope]
96
+ end
97
+ query.concat(status(status))
98
+ unless include_empty.nil?
99
+ Checks.usage("include_empty is true or false.") unless [true, false].include?(include_empty)
100
+ query << ["include_empty", include_empty.to_s]
101
+ end
102
+ query + page(limit, USAGE_LIMIT_MAX, cursor)
103
+ end
104
+
105
+ # `GET /v1/keys`'s query, checked as the API reads it; the cursor is the
106
+ # server's.
107
+ def keys_list(labels, device_id, status, limit, cursor)
108
+ labels(labels) + device(device_id) + status(status) + page(limit, KEYS_LIMIT_MAX, cursor)
109
+ end
110
+
111
+ # A caller's own Idempotency-Key: 1 to 255 printable ASCII characters.
112
+ def idempotency_key!(key)
113
+ return if key.is_a?(String) && key.length.between?(1, IDEMPOTENCY_KEY_CHARS) && key.each_char.all? { |c| c.ord.between?(0x20, 0x7E) }
114
+
115
+ Checks.usage("Idempotency-Key must be sent once, as 1 to 255 printable ASCII characters.")
116
+ end
117
+
118
+ # How a key's 422 entry echoes an input, as the API does: a String cut to
119
+ # its first 64 characters, with "..." only when it was longer; a Hash or
120
+ # an Array never (nil); anything else as it is.
121
+ def echoed(value)
122
+ case value
123
+ when String then value.length > ECHO_CHARS ? "#{value[0, ECHO_CHARS]}..." : value
124
+ when Hash, Array then nil
125
+ else value
126
+ end
127
+ end
128
+
129
+ def key_entry(loc, msg, value)
130
+ Checks.entry("value_error", ["body"] + loc, msg, echoed(value))
131
+ end
132
+
133
+ # A key's name, trimmed, 1 to 48 characters; its characters are the
134
+ # server's to judge.
135
+ def name_entries(name)
136
+ trimmed = name.is_a?(String) ? name.gsub(/\A[[:space:]]+|[[:space:]]+\z/, "") : ""
137
+ return [key_entry(["name"], "Give the key a name.", name)] if trimmed.empty?
138
+ return [key_entry(["name"], "Key names are at most 48 characters.", name)] if trimmed.length > KEY_NAME_CHARS
139
+
140
+ []
141
+ end
142
+
143
+ def labels_entries(labels)
144
+ return [key_entry(["labels"], "labels is an object of names to values.", labels)] unless labels.is_a?(Hash)
145
+ return [key_entry(["labels"], "A key has at most 16 labels.", labels)] if labels.size > KEY_MAX_LABELS
146
+
147
+ labels.each do |name, value|
148
+ name = name.to_s if name.is_a?(Symbol)
149
+ unless name.is_a?(String) && name.match?(LABEL_NAME_RULE)
150
+ return [key_entry(["labels"], "A label's name is 1 to 32 of a-z, 0-9, _ . and -.", labels)]
151
+ end
152
+ unless value.is_a?(String) && value.length.between?(1, LABEL_VALUE_CHARS) && !value.match?(/\p{Cc}/)
153
+ return [key_entry(["labels", name], "A label's value is 1 to 64 characters of text.", labels)]
154
+ end
155
+ end
156
+ []
157
+ end
158
+
159
+ # `daily_spend_cap`: `{"usd"}` from 0.01 to 1,000 in whole cents.
160
+ def cap_entries(cap)
161
+ usd = cap.is_a?(Hash) ? (cap.key?("usd") ? cap["usd"] : cap[:usd]) : nil
162
+ ok = usd.is_a?(Numeric) && usd.to_f.finite?
163
+ cents = ok ? (usd * 100).round : 0
164
+ return [] if ok && ((usd * 100) - cents).abs < 1e-6 && cents >= 1 && usd <= SPEND_CAP_MAX_USD
165
+
166
+ [key_entry(["daily_spend_cap"], "daily_spend_cap.usd is from 0.01 to 1000, in cents, or null.", cap)]
167
+ end
168
+
169
+ # A key's create (`clears` false) or update body, the fields given only,
170
+ # checked in the body's order: the first fault is the API's 422 entry. A
171
+ # limit's ceiling, the expiry's window and a name's characters are the
172
+ # server's.
173
+ def key_fields!(body, clears:)
174
+ entries = body.key?("name") ? name_entries(body["name"]) : []
175
+ if entries.empty?
176
+ entries << key_entry(["device_id"], DEVICE_ID_RULE, body["device_id"]) if body.key?("device_id") && !device_ok?(body["device_id"])
177
+ entries.concat(labels_entries(body["labels"])) if body.key?("labels") && !(clears && body["labels"].nil?)
178
+ if body.key?("limits") && !(clears && body["limits"].nil?) && !body["limits"].is_a?(Hash)
179
+ entries << key_entry(["limits"], "limits is an object, or null.", body["limits"])
180
+ end
181
+ entries.concat(cap_entries(body["daily_spend_cap"])) if body.key?("daily_spend_cap") && !(clears && body["daily_spend_cap"].nil?)
182
+ end
183
+ raise Checks.invalid(entries.first(1)) unless entries.empty?
184
+ end
185
+
186
+ # A bulk revoke's labels (at least one) and `device_ids` (at most 1,000).
187
+ def revoke_by_labels!(labels, device_ids)
188
+ entries = labels_entries(labels)
189
+ if entries.empty? && labels.empty?
190
+ entries = [key_entry(["labels"], "A bulk revoke names at least one label: every key it revokes has them all.", labels)]
191
+ end
192
+ if entries.empty? && !device_ids.nil?
193
+ if !device_ids.is_a?(Array) || device_ids.size > MAX_REVOKE_DEVICES
194
+ entries = [key_entry(["device_ids"], "device_ids is a list of at most 1,000 device ids.", device_ids)]
195
+ elsif (bad = device_ids.find { |d| !device_ok?(d) })
196
+ entries = [key_entry(["device_id"], DEVICE_ID_RULE, bad)]
197
+ end
198
+ end
199
+ raise Checks.invalid(entries) unless entries.empty?
200
+ end
201
+
202
+ def path(path, query)
203
+ return path if query.empty?
204
+
205
+ "#{path}?#{query.map { |k, v| "#{k}=#{URI.encode_uri_component(v.to_s)}" }.join('&')}"
206
+ end
207
+
208
+ def segment(id)
209
+ raise ArgumentError, "a key id is required" if id.nil? || id.to_s.empty?
210
+
211
+ URI.encode_uri_component(id.to_s)
212
+ end
213
+ end
214
+ private_constant :KeyFilters
215
+
216
+ # Per-key usage: what each key used and cost over a period. A Full access
217
+ # key reads every key of its workspace, a Manage device keys key its
218
+ # Decisions-only keys. Every call returns the API's object as sent, a Hash
219
+ # with the API's field names, and retries as any read.
220
+ class KeyUsage
221
+ def initialize(client)
222
+ @client = client
223
+ end
224
+
225
+ def inspect
226
+ "#<#{self.class.name}>"
227
+ end
228
+
229
+ # One key's usage (`GET /v1/keys/{key_id}/usage`). `start:` and `end:`
230
+ # are RFC 3339 times, rounded to the hour by the API; left out, the UTC
231
+ # month so far. `granularity:` ("none", "hour", "day" or "month") adds
232
+ # `buckets`, and `group_by:` ("model" or "channel") `by_model` or
233
+ # `by_channel`.
234
+ def key(key_id, start: nil, end: nil, granularity: nil, group_by: nil)
235
+ query = KeyFilters.period(start, binding.local_variable_get(:end), granularity, group_by)
236
+ get(KeyFilters.path("/v1/keys/#{KeyFilters.segment(key_id)}/usage", query))
237
+ end
238
+
239
+ # One page of every key's usage over a period (`GET /v1/usage`):
240
+ # `{period, as_of, items, next_cursor}`. `labels:` keeps the keys that
241
+ # have them all, `key_ids:` (at most 100) the keys named, `scope:`
242
+ # ("full", "decide" or "keys") and `status:` ("active", "expired",
243
+ # "revoked" or "all") the keys of that kind or state; `include_empty:`
244
+ # lists keys with no usage too. Pass `next_cursor` as `cursor:` for the
245
+ # next page, until it is nil.
246
+ def list(start:, end:, granularity: nil, group_by: nil, labels: nil, device_id: nil, key_ids: nil, scope: nil,
247
+ status: nil, include_empty: nil, limit: nil, cursor: nil)
248
+ query = KeyFilters.usage_list(start, binding.local_variable_get(:end), granularity, group_by, labels, device_id,
249
+ key_ids, scope, status, include_empty, limit, cursor)
250
+ get(KeyFilters.path("/v1/usage", query))
251
+ end
252
+
253
+ # Every key's usage over the period: #list's pages, `next_cursor`
254
+ # followed until it is nil, their `items` in order. A page can hold fewer
255
+ # keys than `limit`, so only the cursor says when the list is done.
256
+ def all(start:, end:, granularity: nil, group_by: nil, labels: nil, device_id: nil, key_ids: nil, scope: nil,
257
+ status: nil, include_empty: nil, limit: nil)
258
+ finish = binding.local_variable_get(:end)
259
+ items = []
260
+ cursor = nil
261
+ loop do
262
+ page = list(start: start, end: finish, granularity: granularity, group_by: group_by, labels: labels,
263
+ device_id: device_id, key_ids: key_ids, scope: scope, status: status,
264
+ include_empty: include_empty, limit: limit, cursor: cursor)
265
+ items.concat(Array(page.is_a?(Hash) ? page["items"] : nil))
266
+ cursor = page.is_a?(Hash) ? page["next_cursor"] : nil
267
+ return items if cursor.nil? || cursor.to_s.empty?
268
+ end
269
+ end
270
+
271
+ private
272
+
273
+ def get(path)
274
+ @client.__send__(:request, "GET", path, nil).first
275
+ end
276
+ end
277
+
278
+ # Decisions-only keys for devices, made, listed, changed and revoked with a
279
+ # Manage device keys key. Every call returns the API's object as sent, a
280
+ # Hash with the API's field names.
281
+ #
282
+ # #create sends an Idempotency-Key: the one given (one per enrollment, so a
283
+ # retried enrollment gets the same key and secret back), else a new UUID
284
+ # per call, the same on every attempt of it. It retries as every call does,
285
+ # and also on a 409 `idempotency_in_progress` after its Retry-After; a 409
286
+ # `idempotency_key_reused` is raised. The other calls retry as any call.
287
+ class Keys
288
+ # The value of an argument not given, so a field given as nil is sent as
289
+ # null, which clears it.
290
+ UNSET = Object.new.freeze
291
+ private_constant :UNSET
292
+
293
+ def initialize(client)
294
+ @client = client
295
+ end
296
+
297
+ def inspect
298
+ "#<#{self.class.name}>"
299
+ end
300
+
301
+ # Makes a Decisions-only key (`POST /v1/keys`): `{key, secret}`. The
302
+ # secret is shown in this answer only (and again to a retry with the same
303
+ # `idempotency_key:` within 24 hours); hand it to the device and never
304
+ # log it. Each field is sent only when given. The name, `device_id`,
305
+ # labels, the shape of `limits` and the spend cap are checked first, each
306
+ # fault the API's 422 ValidationError, then the `idempotency_key`.
307
+ def create(name, device_id: nil, labels: nil, limits: nil, daily_spend_cap: nil, expires_at: nil,
308
+ may_search_collections: nil, idempotency_key: nil)
309
+ body = { "name" => name }
310
+ { "device_id" => device_id, "labels" => labels, "limits" => limits, "daily_spend_cap" => daily_spend_cap,
311
+ "expires_at" => expires_at, "may_search_collections" => may_search_collections }.each do |field, value|
312
+ body[field] = value unless value.nil?
313
+ end
314
+ KeyFilters.key_fields!(body, clears: false)
315
+ KeyFilters.idempotency_key!(idempotency_key) unless idempotency_key.nil?
316
+ parsed, = @client.__send__(:request, "POST", "/v1/keys", body, policy: :idempotent,
317
+ extra_headers: { "Idempotency-Key" => idempotency_key || SecureRandom.uuid })
318
+ parsed
319
+ end
320
+
321
+ # One page of the workspace's Decisions-only keys, newest first:
322
+ # `{items, next_cursor}`. `labels:` keeps the keys that have them all,
323
+ # `status:` is "active" (the API's default), "expired", "revoked" or
324
+ # "all", and `limit:` 1 to 200. Pass `next_cursor` as `cursor:` for the
325
+ # next page, until it is nil.
326
+ def list(labels: nil, device_id: nil, status: nil, limit: nil, cursor: nil)
327
+ call("GET", KeyFilters.path("/v1/keys", KeyFilters.keys_list(labels, device_id, status, limit, cursor)))
328
+ end
329
+
330
+ # Every key #list finds: its pages, `next_cursor` followed until it is
331
+ # nil, their `items` in order.
332
+ def all(labels: nil, device_id: nil, status: nil, limit: nil)
333
+ items = []
334
+ cursor = nil
335
+ loop do
336
+ page = list(labels: labels, device_id: device_id, status: status, limit: limit, cursor: cursor)
337
+ items.concat(Array(page.is_a?(Hash) ? page["items"] : nil))
338
+ cursor = page.is_a?(Hash) ? page["next_cursor"] : nil
339
+ return items if cursor.nil? || cursor.to_s.empty?
340
+ end
341
+ end
342
+
343
+ # Changes a key (`PATCH /v1/keys/{id}`), sending only the fields given:
344
+ # `labels:` replaces the key's whole set, and nil clears `labels`,
345
+ # `limits`, `daily_spend_cap` or `expires_at`. A key's `device_id` never
346
+ # changes. Returns the key.
347
+ def update(key_id, name: UNSET, labels: UNSET, limits: UNSET, daily_spend_cap: UNSET, expires_at: UNSET,
348
+ may_search_collections: UNSET)
349
+ body = { "name" => name, "labels" => labels, "limits" => limits, "daily_spend_cap" => daily_spend_cap,
350
+ "expires_at" => expires_at, "may_search_collections" => may_search_collections }
351
+ .reject { |_, value| value.equal?(UNSET) }
352
+ KeyFilters.key_fields!(body, clears: true)
353
+ Checks.usage("Nothing to change.") if body.empty?
354
+
355
+ call("PATCH", "/v1/keys/#{KeyFilters.segment(key_id)}", body)
356
+ end
357
+
358
+ # Revokes a key at once (`DELETE /v1/keys/{id}`): `{id, revoked}`. The
359
+ # next call with it gets a 401; a revocation cannot be undone.
360
+ def revoke(key_id)
361
+ call("DELETE", "/v1/keys/#{KeyFilters.segment(key_id)}")
362
+ end
363
+
364
+ # Revokes every active key that has all of `labels` (and, with
365
+ # `device_ids:`, is one of those devices), at most 10,000 a call
366
+ # (`POST /v1/keys/revoke`): `{revoked, more}`. Call it again while `more`
367
+ # is true. With `dry_run: true` it revokes nothing and answers
368
+ # `{would_revoke, more}`.
369
+ def revoke_by_labels(labels, device_ids: nil, dry_run: nil)
370
+ KeyFilters.revoke_by_labels!(labels, device_ids)
371
+ body = { "labels" => labels }
372
+ body["device_ids"] = device_ids unless device_ids.nil?
373
+ body["dry_run"] = dry_run unless dry_run.nil?
374
+ call("POST", "/v1/keys/revoke", body)
375
+ end
376
+
377
+ private
378
+
379
+ def call(method, path, body = nil)
380
+ @client.__send__(:request, method, path, body).first
381
+ end
382
+ end
383
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DecisionNode
4
+ # Question builders. Each returns the question's wire form as a plain Hash,
5
+ # so a builder and a Hash copied from the API documentation are interchangeable.
6
+ #
7
+ # DecisionNode.truth("Escalate to a human now?")
8
+ #
9
+ # or, to call them without the prefix:
10
+ #
11
+ # include DecisionNode::Questions
12
+ # truth("Escalate to a human now?")
13
+ #
14
+ # Instructions, descriptions and levels take a string or any JSON value.
15
+ module Questions
16
+ module_function
17
+
18
+ # A yes-or-no question answered as a probability.
19
+ # The criteria are left out when neither side is given.
20
+ def truth(instructions = nil, if_true: nil, if_false: nil)
21
+ question = { "type" => "truth" }
22
+ question["instructions"] = instructions unless instructions.nil?
23
+ criteria = {}
24
+ criteria["true"] = if_true unless if_true.nil?
25
+ criteria["false"] = if_false unless if_false.nil?
26
+ question["criteria"] = criteria unless criteria.empty?
27
+ question
28
+ end
29
+
30
+ # One option of a set. `options` is a Hash of name to description, or an
31
+ # Array of names (each description nil). Names are sent exactly as given.
32
+ def choice(instructions = nil, options = nil)
33
+ criteria =
34
+ case options
35
+ when nil then {}
36
+ when Array then options.each_with_object({}) { |name, h| h[name.to_s] = nil }
37
+ when Hash then options.each_with_object({}) { |(name, desc), h| h[name.to_s] = desc }
38
+ else raise ArgumentError, "options must be a Hash of name to description or an Array of names"
39
+ end
40
+ question = { "type" => "choice" }
41
+ question["instructions"] = instructions unless instructions.nil?
42
+ question["criteria"] = criteria
43
+ question
44
+ end
45
+
46
+ # A place on an ordered scale. `levels` is listed from low to high.
47
+ def score(instructions = nil, levels = nil)
48
+ question = { "type" => "score" }
49
+ question["instructions"] = instructions unless instructions.nil?
50
+ question["criteria"] = levels.nil? ? [] : levels.to_a
51
+ question
52
+ end
53
+
54
+ # A value on a grid of numbers from `min` to `max` in steps of `step`.
55
+ # An argument left out is left out of the wire form, so the server's
56
+ # defaults apply: 0, 255 and 1.
57
+ #
58
+ # number("How many cars are visible?", min: 0, max: 50)
59
+ # number("Rating?", min: 0, max: 5, step: 0.5)
60
+ def number(instructions = nil, min: nil, max: nil, step: nil)
61
+ question = { "type" => "number" }
62
+ question["instructions"] = instructions unless instructions.nil?
63
+ question["min"] = min unless min.nil?
64
+ question["max"] = max unless max.nil?
65
+ question["step"] = step unless step.nil?
66
+ question
67
+ end
68
+
69
+ # One spot in an image, answered with its `x` and `y`. `image` is the id
70
+ # of the image to look in (a string), needed when the request has two or
71
+ # more; a reference by number ("Picture 2") goes in the instructions.
72
+ # `units` is "pixels" (the API's default) or "normalized". `max` (an
73
+ # Integer from 2 to 16) asks for up to that many spots, one per thing the
74
+ # instructions describe, answered as a PointsAnswer. Each is left out of
75
+ # the wire form when not given.
76
+ #
77
+ # point("Point to the yellow package closest to the robot arm.")
78
+ # point("Each person standing on the platform.", max: 6)
79
+ def point(instructions = nil, image: nil, units: nil, max: nil)
80
+ place("point", instructions, image, units, max)
81
+ end
82
+
83
+ # A rectangle in an image, answered with its top-left (`x1`, `y1`) and
84
+ # bottom-right (`x2`, `y2`) corners. `image`, `units` and `max` as for
85
+ # #point; with `max` the answer is a BoxesAnswer.
86
+ #
87
+ # box("Draw a box around the damaged package.", image: "belt")
88
+ # box("Every car in the lot.", image: "lot", max: 8)
89
+ def box(instructions = nil, image: nil, units: nil, max: nil)
90
+ place("box", instructions, image, units, max)
91
+ end
92
+
93
+ def place(type, instructions, image, units, most)
94
+ question = { "type" => type }
95
+ question["instructions"] = instructions unless instructions.nil?
96
+ question["image"] = image unless image.nil?
97
+ question["units"] = units unless units.nil?
98
+ question["max"] = most unless most.nil?
99
+ question
100
+ end
101
+ private_class_method :place
102
+ end
103
+
104
+ class << self
105
+ # See Questions.truth.
106
+ def truth(instructions = nil, if_true: nil, if_false: nil)
107
+ Questions.truth(instructions, if_true: if_true, if_false: if_false)
108
+ end
109
+
110
+ # See Questions.choice.
111
+ def choice(instructions = nil, options = nil)
112
+ Questions.choice(instructions, options)
113
+ end
114
+
115
+ # See Questions.score.
116
+ def score(instructions = nil, levels = nil)
117
+ Questions.score(instructions, levels)
118
+ end
119
+
120
+ # See Questions.number.
121
+ def number(instructions = nil, min: nil, max: nil, step: nil)
122
+ Questions.number(instructions, min: min, max: max, step: step)
123
+ end
124
+
125
+ # See Questions.point.
126
+ def point(instructions = nil, image: nil, units: nil, max: nil)
127
+ Questions.point(instructions, image: image, units: units, max: max)
128
+ end
129
+
130
+ # See Questions.box.
131
+ def box(instructions = nil, image: nil, units: nil, max: nil)
132
+ Questions.box(instructions, image: image, units: units, max: max)
133
+ end
134
+ end
135
+ end