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,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
|