calmlens 0.1.1 → 0.17.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 +4 -4
- data/README.md +133 -58
- data/lib/calmlens/assets.rb +169 -50
- data/lib/calmlens/cdn.rb +29 -7
- data/lib/calmlens/error_codes.rb +82 -4
- data/lib/calmlens/errors.rb +26 -17
- data/lib/calmlens/events.rb +15 -12
- data/lib/calmlens/folders.rb +13 -6
- data/lib/calmlens/hash_lists.rb +95 -0
- data/lib/calmlens/internal.rb +13 -7
- data/lib/calmlens/moderation_context.rb +55 -0
- data/lib/calmlens/resource.rb +23 -6
- data/lib/calmlens/server_client.rb +36 -2
- data/lib/calmlens/session.rb +170 -68
- data/lib/calmlens/testing/classification.rb +21 -0
- data/lib/calmlens/testing/fake_api.rb +946 -233
- data/lib/calmlens/testing/mock_decision.rb +158 -0
- data/lib/calmlens/testing/placeholder.rb +111 -0
- data/lib/calmlens/testing.rb +282 -39
- data/lib/calmlens/text.rb +7 -5
- data/lib/calmlens/transforms.rb +37 -18
- data/lib/calmlens/upload_body.rb +20 -9
- data/lib/calmlens/upload_tokens.rb +14 -6
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens/webhooks.rb +127 -32
- data/lib/calmlens.rb +2 -0
- data/sig/calmlens.rbs +168 -39
- data/sig/error_codes.rbs +41 -2
- data/sig/qualifiers.rbs +141 -0
- data/sig/types.rbs +340 -162
- metadata +8 -2
data/lib/calmlens/assets.rb
CHANGED
|
@@ -7,21 +7,32 @@ module CalmLens
|
|
|
7
7
|
# (`asset_id:`) or the API's camelCase (`'assetId'`); keys inside `metadata` are sent unchanged.
|
|
8
8
|
# Any method also accepts `request_options: { idempotency_key:, timeout:, open_timeout:, max_retries: }`.
|
|
9
9
|
# Results are Hashes with the API's camelCase string keys. Fields and enum values added to the API
|
|
10
|
-
# later appear as they are, so compare `moderation.status` and similar values with known strings
|
|
10
|
+
# later appear as they are, so compare `classification.moderation.status` and similar values with known strings
|
|
11
11
|
# and treat anything else as not ready.
|
|
12
12
|
class Assets < Resource
|
|
13
|
-
SUBMIT_QUERY_KEYS = %w[name description processingOptions visibility metadata externalId externalUrl
|
|
13
|
+
SUBMIT_QUERY_KEYS = %w[name description processingOptions visibility metadata authorId context externalId externalUrl
|
|
14
14
|
folderId uploadPresetId].freeze
|
|
15
15
|
MIN_MULTIPART_IMAGE_BYTES = 10 * 1024 * 1024
|
|
16
16
|
MAX_MULTIPART_IMAGE_BYTES = 20 * 1024 * 1024
|
|
17
|
-
#
|
|
18
|
-
# or once its
|
|
19
|
-
|
|
17
|
+
# The moderation status before a decision; {#wait_for_decision} returns once an asset has any
|
|
18
|
+
# other, or once its classification status is in {FINISHED_PROCESSING_STATUSES}.
|
|
19
|
+
PENDING_MODERATION_STATUS = 'pending'
|
|
20
20
|
# Processing statuses after which no decision will arrive without a retry.
|
|
21
21
|
FINISHED_PROCESSING_STATUSES = %w[failed].freeze
|
|
22
22
|
DECISION_POLL_FIRST_DELAY = 0.5
|
|
23
|
+
# Each wait between decision checks is this many times the one before, up to DECISION_POLL_MAX_DELAY.
|
|
24
|
+
DECISION_POLL_FACTOR = 1.5
|
|
23
25
|
DECISION_POLL_MAX_DELAY = 5.0
|
|
24
26
|
DEFAULT_DECISION_TIMEOUT = 60
|
|
27
|
+
# The lifetime of a signed URL when none is given: 15 minutes.
|
|
28
|
+
DEFAULT_SIGNED_URL_SECONDS = 15 * 60
|
|
29
|
+
# The longest signed URL lifetime the API accepts: 24 hours.
|
|
30
|
+
MAX_SIGNED_URL_SECONDS = 24 * 60 * 60
|
|
31
|
+
# The operations {#batch} runs: `delete` deletes each asset with its child assets and stored
|
|
32
|
+
# content; `remove-content` removes its stored content and keeps the record.
|
|
33
|
+
BATCH_OPERATIONS = %w[delete remove-content].freeze
|
|
34
|
+
# The most assets one {#batch} request takes.
|
|
35
|
+
MAX_BATCH_ASSETS = 500
|
|
25
36
|
|
|
26
37
|
# Searches assets by meaning or keyword.
|
|
27
38
|
# @option options [String] :query
|
|
@@ -34,13 +45,13 @@ module CalmLens
|
|
|
34
45
|
|
|
35
46
|
# Lists one page of assets.
|
|
36
47
|
# @option options [String] :cursor from the previous page
|
|
37
|
-
# @option options [Integer] :page_size
|
|
38
|
-
# @option options [String] :filter an RSQL filter such as `moderation.status==
|
|
39
|
-
# @option options [Array<Hash>] :ordering `[{field: 'createdAt', direction: '
|
|
40
|
-
# @return [Hash]
|
|
48
|
+
# @option options [Integer] :page_size 1 to 100, default 50
|
|
49
|
+
# @option options [String] :filter an RSQL filter such as `classification.moderation.status==allowed`
|
|
50
|
+
# @option options [Array<Hash>] :ordering `[{field: 'createdAt', direction: 'desc'}]`
|
|
51
|
+
# @return [Hash] a cursor page: items, nextCursor, hasMore, pageSize and ordering.
|
|
41
52
|
def list(options = {})
|
|
42
53
|
call(options) do |api, request|
|
|
43
|
-
request('GET', project('assets
|
|
54
|
+
request('GET', project('assets'), query: api, request_options: request)
|
|
44
55
|
end
|
|
45
56
|
end
|
|
46
57
|
|
|
@@ -88,9 +99,10 @@ module CalmLens
|
|
|
88
99
|
end
|
|
89
100
|
end
|
|
90
101
|
|
|
91
|
-
# Waits until an asset's `moderation.status` is `
|
|
92
|
-
# `
|
|
93
|
-
# seconds before the second check and 1.5 times
|
|
102
|
+
# Waits until an asset's `classification.moderation.status` is no longer `pending` (`allowed`,
|
|
103
|
+
# `limited`, `age-restricted`, `blocked` or `purged`), or its `classification.status` is `failed`, and returns
|
|
104
|
+
# the asset. It retrieves the asset, waiting 0.5 seconds before the second check and 1.5 times
|
|
105
|
+
# longer each time after that, up to 5 seconds.
|
|
94
106
|
# Webhooks report decisions without polling.
|
|
95
107
|
#
|
|
96
108
|
# `timeout:` is the whole wait in seconds; `request_options: {timeout:}` still limits each request.
|
|
@@ -99,7 +111,7 @@ module CalmLens
|
|
|
99
111
|
# @option options [Numeric] :timeout seconds to wait for a decision (default 60)
|
|
100
112
|
# @return [Hash] the decided asset
|
|
101
113
|
# @raise [TimeoutError] `sdk/timeout` when no decision arrives in time; `details` holds
|
|
102
|
-
# `'assetId'` and the last `'
|
|
114
|
+
# `'assetId'` and the last `'classificationStatus'` and `'moderationStatus'`.
|
|
103
115
|
def wait_for_decision(options)
|
|
104
116
|
call(options) do |api, request|
|
|
105
117
|
timeout = api.fetch('timeout', DEFAULT_DECISION_TIMEOUT)
|
|
@@ -121,7 +133,7 @@ module CalmLens
|
|
|
121
133
|
end
|
|
122
134
|
end
|
|
123
135
|
|
|
124
|
-
# Processes an asset whose `
|
|
136
|
+
# Processes an asset whose `classification.status` is `failed` again.
|
|
125
137
|
# @option options [String] :asset_id
|
|
126
138
|
# @return [Hash] the asset
|
|
127
139
|
def retry(options)
|
|
@@ -130,8 +142,40 @@ module CalmLens
|
|
|
130
142
|
end
|
|
131
143
|
end
|
|
132
144
|
|
|
145
|
+
# Records a reviewer's decision, replacing the policy's: `classification.moderation.status`
|
|
146
|
+
# becomes the decision, the decision and note join `classification.moderation.manualReviews` and
|
|
147
|
+
# the matching `asset.<status>` event is sent with `decided-by: reviewer`. An asset still pending
|
|
148
|
+
# moderation can only be blocked or purged. Purging needs `confirm_purge: true` and the
|
|
149
|
+
# `assets:delete` permission, and permanently deletes the stored content. Not retried: it carries
|
|
150
|
+
# no `Idempotency-Key`.
|
|
151
|
+
# @option options [String] :asset_id
|
|
152
|
+
# @option options [String] :decision `allowed`, `limited`, `age-restricted`, `blocked` or `purged`
|
|
153
|
+
# @option options [String] :note why, 1 to 1499 characters
|
|
154
|
+
# @option options [Boolean] :confirm_purge must be true to purge
|
|
155
|
+
# @return [Hash] the asset
|
|
156
|
+
def decide(options)
|
|
157
|
+
call(options) do |api, request|
|
|
158
|
+
request('POST', "/assets/#{Internal.segment(api['assetId'])}/decision", body: without(api, 'assetId'),
|
|
159
|
+
request_options: request)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Completes the policy evidence behind a decided asset's moderation decision: the report's
|
|
164
|
+
# `classification.moderation.explanationComplete` becomes `true` and every action stays as decided.
|
|
165
|
+
# With `rerun: true` the asset is moderated again with the project's current policy and returns
|
|
166
|
+
# in `processing`; use it when the policy changed since the decision (`asset/policy-changed`).
|
|
167
|
+
# @option options [String] :asset_id
|
|
168
|
+
# @option options [Boolean] :rerun
|
|
169
|
+
# @return [Hash] the asset
|
|
170
|
+
def explain(options)
|
|
171
|
+
call(options) do |api, request|
|
|
172
|
+
request('POST', "/assets/#{Internal.segment(api['assetId'])}/explain",
|
|
173
|
+
body: api['rerun'] ? { 'rerun' => true } : {}, request_options: request)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
133
177
|
# Removes the stored content and returns the asset with `content.status` `removed` (reason
|
|
134
|
-
# `requested`); the record and
|
|
178
|
+
# `requested`); the record and classification results are kept.
|
|
135
179
|
# @option options [String] :asset_id
|
|
136
180
|
# @return [Hash] the asset
|
|
137
181
|
def delete_content(options)
|
|
@@ -150,6 +194,28 @@ module CalmLens
|
|
|
150
194
|
end
|
|
151
195
|
end
|
|
152
196
|
|
|
197
|
+
# Deletes up to 500 assets (`operation: 'delete'`) or removes their stored content
|
|
198
|
+
# (`'remove-content'`) in one request. Every asset must exist in the project and the key needs
|
|
199
|
+
# `assets:delete` for its folder, or nothing is queued. Returns once the operation is queued; the
|
|
200
|
+
# work runs in the background and records `asset.deleted` or `asset.content-removed` for each
|
|
201
|
+
# asset. Assets under a safety hold are skipped. Not retried: it carries no `Idempotency-Key`.
|
|
202
|
+
# @option options [Array<String>] :asset_ids 1 to 500 asset IDs
|
|
203
|
+
# @option options [String] :operation one of {BATCH_OPERATIONS}
|
|
204
|
+
# @return [Hash] `{'queued' => true}`
|
|
205
|
+
def batch(options)
|
|
206
|
+
call(options) do |api, request|
|
|
207
|
+
ids = api['assetIds']
|
|
208
|
+
unless ids.is_a?(Array) && ids.size.between?(1, MAX_BATCH_ASSETS)
|
|
209
|
+
raise Internal.invalid("asset_ids must list 1 to #{MAX_BATCH_ASSETS} asset IDs")
|
|
210
|
+
end
|
|
211
|
+
raise Internal.invalid('Every asset ID must be a non-empty string') unless ids.all? { |id| id.is_a?(String) && !id.empty? }
|
|
212
|
+
raise Internal.invalid("operation must be one of #{BATCH_OPERATIONS.join(', ')}") unless BATCH_OPERATIONS.include?(api['operation'])
|
|
213
|
+
|
|
214
|
+
request('POST', project('assets/batch'), body: { 'assetIds' => ids, 'operation' => api['operation'] },
|
|
215
|
+
request_options: request)
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
153
219
|
# Moves an asset into a folder, or to the project root with `folder_id: nil`.
|
|
154
220
|
# @option options [String] :asset_id
|
|
155
221
|
# @option options [String, nil] :folder_id
|
|
@@ -163,7 +229,7 @@ module CalmLens
|
|
|
163
229
|
|
|
164
230
|
# Creates a time-limited URL that delivers private content without an API key.
|
|
165
231
|
# @option options [String] :asset_id
|
|
166
|
-
# @option options [String] :resource `
|
|
232
|
+
# @option options [String] :resource `rendered` (the API's default) or `original`, which takes no lens or transform
|
|
167
233
|
# @option options [Integer] :expires_in_seconds default 900
|
|
168
234
|
# @option options [String] :lens a lens identifier
|
|
169
235
|
# @option options [String, Hash, Array<Hash>] :transform a selector String or structured steps,
|
|
@@ -171,16 +237,19 @@ module CalmLens
|
|
|
171
237
|
# @return [Hash] `{'url', 'expiresAt'}`
|
|
172
238
|
def create_signed_url(options)
|
|
173
239
|
call(options) do |api, request|
|
|
174
|
-
|
|
240
|
+
path = "/assets/#{Internal.segment(api['assetId'])}/signed-url"
|
|
241
|
+
body = { 'expiresInSeconds' => DEFAULT_SIGNED_URL_SECONDS }.merge(without(api, 'assetId'))
|
|
242
|
+
check_signed_url(body)
|
|
175
243
|
body['transform'] = Transforms.selector(body['transform']) if body.key?('transform')
|
|
176
|
-
request('POST',
|
|
177
|
-
body:
|
|
244
|
+
request('POST', path,
|
|
245
|
+
body: body,
|
|
178
246
|
request_options: request)
|
|
179
247
|
end
|
|
180
248
|
end
|
|
181
249
|
|
|
182
250
|
# Uploads content and queues moderation; the call returns before classification finishes.
|
|
183
|
-
# Watch for `asset.
|
|
251
|
+
# Watch for `asset.allowed`, `asset.limited`, `asset.age-restricted`, `asset.blocked` and
|
|
252
|
+
# `asset.purged` events or retrieve the asset later.
|
|
184
253
|
#
|
|
185
254
|
# Give exactly one of `content:` (a whole file), `part:` (the first part of a multipart upload) or
|
|
186
255
|
# `url:` (a web page or file to fetch). Content is `{type:, plain_text:}`, `{type:, base64:}` or
|
|
@@ -194,6 +263,11 @@ module CalmLens
|
|
|
194
263
|
# @option options [Hash] :part `{type:, data:, is_final:}` for the first multipart part
|
|
195
264
|
# @option options [Integer] :total_size_in_bytes the whole file's size, for multipart uploads
|
|
196
265
|
# @option options [String] :url
|
|
266
|
+
# @option options [String] :author_id your identifier for who wrote the content; CalmLens only
|
|
267
|
+
# compares it with the author IDs in `context`, to tell which posts share a speaker.
|
|
268
|
+
# @option options [Hash] :context `{items: [{text:, author_id:}]}`, earlier posts in the
|
|
269
|
+
# conversation, oldest first. Only the posts CalmLens uses are sent: the most recent
|
|
270
|
+
# {ModerationContext::USED_ITEMS} that fit in {ModerationContext::USED_CHARACTERS} characters.
|
|
197
271
|
# @return [Hash] the asset
|
|
198
272
|
def create(options)
|
|
199
273
|
call(options) { |api, request| submit(api, request, true) }
|
|
@@ -201,25 +275,26 @@ module CalmLens
|
|
|
201
275
|
|
|
202
276
|
# Uploads content and waits for the moderation decision where the content type supports it.
|
|
203
277
|
# Audio and video are always processed asynchronously. Takes the options of {#create}.
|
|
204
|
-
# @return [Hash] the asset;
|
|
205
|
-
# `
|
|
206
|
-
|
|
278
|
+
# @return [Hash] the asset; show it to everyone only when `classification.moderation.status` is
|
|
279
|
+
# `allowed` or `limited` (reduce the reach of `limited` assets). Serve `age-restricted` assets
|
|
280
|
+
# only behind your own age check, through signed URLs.
|
|
281
|
+
def classify(options)
|
|
207
282
|
call(options) do |api, request|
|
|
208
283
|
content = api['content']
|
|
209
284
|
if content.is_a?(Hash) && content.key?('plainText') && (!content['plainText'].is_a?(String) || content['plainText'].strip.empty?)
|
|
210
|
-
raise Internal.invalid('Text to
|
|
285
|
+
raise Internal.invalid('Text to classify must be a non-empty string', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
211
286
|
end
|
|
212
287
|
|
|
213
288
|
submit(api, request, false)
|
|
214
289
|
end
|
|
215
290
|
end
|
|
216
291
|
|
|
217
|
-
# Uploads a later part of a multipart upload started with {#create} or {#
|
|
292
|
+
# Uploads a later part of a multipart upload started with {#create} or {#classify} and `part:`.
|
|
218
293
|
# The queue mode chosen at the start applies; pass `queue:` only to override it.
|
|
219
294
|
# @option options [String] :asset_id
|
|
220
295
|
# @option options [Hash] :part `{data:, part_number:, is_final:}`; part numbers start at 2.
|
|
221
296
|
# @option options [Boolean] :queue
|
|
222
|
-
# @return [Hash] the asset; `
|
|
297
|
+
# @return [Hash] the asset; `classification.status` stays `uploading` until the final part.
|
|
223
298
|
def upload_part(options)
|
|
224
299
|
call(options) do |api, request|
|
|
225
300
|
path = "/assets/#{Internal.segment(api['assetId'])}/part"
|
|
@@ -232,36 +307,39 @@ module CalmLens
|
|
|
232
307
|
ErrorCodes::INVALID_UPLOAD_PART_NUMBER)
|
|
233
308
|
end
|
|
234
309
|
|
|
235
|
-
body = UploadBody.from(part, multipart: true)
|
|
310
|
+
body = UploadBody.from(part, multipart: true, part_size: !@session.defer_upload_checks)
|
|
236
311
|
query = { 'partNumber' => number, 'isFinal' => part.fetch('isFinal', false), 'sizeInBytes' => body.size }
|
|
237
312
|
query['queue'] = api['queue'] if api.key?('queue')
|
|
238
|
-
request('POST', path, query: query, raw: body, idempotent: true, request_options: request,
|
|
239
|
-
|
|
313
|
+
expect_asset(request('POST', path, query: query, raw: body, idempotent: true, request_options: request, upload: true,
|
|
314
|
+
headers: { 'Content-Type' => 'application/octet-stream', 'X-CalmLens-Upload' => 'part' }))
|
|
240
315
|
end
|
|
241
316
|
end
|
|
242
317
|
|
|
243
318
|
private
|
|
244
319
|
|
|
320
|
+
# Waits between decision checks; a mock client's waits take no real time.
|
|
321
|
+
def sleep(seconds) = @session.pause(seconds)
|
|
322
|
+
|
|
245
323
|
def poll_for_decision(asset_id, request_options, timeout)
|
|
246
|
-
deadline =
|
|
324
|
+
deadline = @session.monotonic + timeout
|
|
247
325
|
delay = DECISION_POLL_FIRST_DELAY
|
|
248
326
|
loop do
|
|
249
327
|
asset = retrieve('assetId' => asset_id, 'request_options' => request_options)
|
|
250
|
-
processing = asset.dig('
|
|
251
|
-
moderation = asset.dig('moderation', 'status')
|
|
252
|
-
return asset if
|
|
328
|
+
processing = asset.dig('classification', 'status')
|
|
329
|
+
moderation = asset.dig('classification', 'moderation', 'status')
|
|
330
|
+
return asset if moderation != PENDING_MODERATION_STATUS || FINISHED_PROCESSING_STATUSES.include?(processing)
|
|
253
331
|
|
|
254
|
-
remaining = deadline -
|
|
332
|
+
remaining = deadline - @session.monotonic
|
|
255
333
|
unless remaining.positive?
|
|
256
334
|
raise TimeoutError.new(SdkErrorCodes::TIMEOUT,
|
|
257
335
|
"Asset #{asset['id']} had no moderation decision after #{timeout}s " \
|
|
258
336
|
"(processing #{processing}, moderation #{moderation})",
|
|
259
|
-
details: { 'assetId' => asset['id'], '
|
|
337
|
+
details: { 'assetId' => asset['id'], 'classificationStatus' => processing,
|
|
260
338
|
'moderationStatus' => moderation })
|
|
261
339
|
end
|
|
262
340
|
|
|
263
341
|
sleep([delay, remaining].min)
|
|
264
|
-
delay = [delay *
|
|
342
|
+
delay = [delay * DECISION_POLL_FACTOR, DECISION_POLL_MAX_DELAY].min
|
|
265
343
|
end
|
|
266
344
|
end
|
|
267
345
|
|
|
@@ -271,9 +349,10 @@ module CalmLens
|
|
|
271
349
|
raise Internal.invalid('Supply exactly one of content, part, or url; isFinal belongs in part',
|
|
272
350
|
ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
273
351
|
end
|
|
352
|
+
options = with_used_context(options)
|
|
274
353
|
if options.key?('url')
|
|
275
|
-
return request('POST', project('assets'), query: { 'queue' => queue }, body: options,
|
|
276
|
-
|
|
354
|
+
return expect_asset(request('POST', project('assets'), query: { 'queue' => queue }, body: options,
|
|
355
|
+
idempotent: true, request_options: request_options))
|
|
277
356
|
end
|
|
278
357
|
|
|
279
358
|
multipart = options.key?('part')
|
|
@@ -285,21 +364,61 @@ module CalmLens
|
|
|
285
364
|
raise Internal.invalid('Use uploadPart for multipart upload parts after the first part', ErrorCodes::USE_UPLOAD_PART)
|
|
286
365
|
end
|
|
287
366
|
|
|
288
|
-
body = UploadBody.from(content, multipart: multipart)
|
|
367
|
+
body = UploadBody.from(content, multipart: multipart, part_size: !@session.defer_upload_checks)
|
|
289
368
|
query = options.slice(*SUBMIT_QUERY_KEYS).merge('queue' => queue, 'contentType' => content['type'],
|
|
290
369
|
'sizeInBytes' => body.size)
|
|
291
|
-
if multipart
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
370
|
+
query.merge!(first_part_query(options, content, mime, body)) if multipart
|
|
371
|
+
expect_asset(request('POST', project('assets'),
|
|
372
|
+
query: query, raw: body, idempotent: true, request_options: request_options,
|
|
373
|
+
upload: !content.key?('plainText'),
|
|
374
|
+
headers: { 'Content-Type' => content['type'], 'X-CalmLens-Upload' => multipart ? 'part' : 'content' }))
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Keeps only the earlier posts CalmLens uses, which keeps a raw upload's query short.
|
|
378
|
+
def with_used_context(options)
|
|
379
|
+
return options unless options.key?('context')
|
|
296
380
|
|
|
297
|
-
|
|
298
|
-
|
|
381
|
+
context = options['context']
|
|
382
|
+
raise Internal.invalid('context must be a Hash with items') unless context.is_a?(Hash) && context.key?('items')
|
|
383
|
+
|
|
384
|
+
options.merge('context' => { 'items' => ModerationContext.select_items(context['items']) })
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# The query of a first multipart part: its number, whether it is the last and the declared total.
|
|
388
|
+
def first_part_query(options, content, mime, body)
|
|
389
|
+
total = options['totalSizeInBytes']
|
|
390
|
+
unless total.nil? || (total.is_a?(Integer) && total.positive?)
|
|
391
|
+
raise Internal.invalid('totalSizeInBytes must be a positive Integer', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
299
392
|
end
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
393
|
+
|
|
394
|
+
final = content.fetch('isFinal', false)
|
|
395
|
+
check_image_size(total || body.size, total || final) if mime.start_with?('image/') && !@session.defer_upload_checks
|
|
396
|
+
{ 'partNumber' => 1, 'isFinal' => final, 'totalSizeInBytes' => total }
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Like the API's asset responses: an ID and string statuses for the upload, classification and moderation.
|
|
400
|
+
def expect_asset(data)
|
|
401
|
+
valid = data.is_a?(Hash) && data['id'].is_a?(String) && !data['id'].empty? &&
|
|
402
|
+
data['upload'].is_a?(Hash) && data['upload']['status'].is_a?(String) &&
|
|
403
|
+
data['classification'].is_a?(Hash) && data['classification']['status'].is_a?(String) &&
|
|
404
|
+
data.dig('classification', 'moderation').is_a?(Hash) &&
|
|
405
|
+
data.dig('classification', 'moderation', 'status').is_a?(String)
|
|
406
|
+
return data if valid
|
|
407
|
+
|
|
408
|
+
raise InvalidResponseError.new(SdkErrorCodes::INVALID_RESPONSE, 'Expected an asset response', status: 200)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def check_signed_url(body)
|
|
412
|
+
resource = body.fetch('resource', 'rendered')
|
|
413
|
+
raise Internal.invalid("A signed URL's resource must be original or rendered") unless %w[original rendered].include?(resource)
|
|
414
|
+
if resource == 'original' && (body.key?('lens') || body.key?('transform'))
|
|
415
|
+
raise Internal.invalid('Lenses and transforms apply only to the rendered resource')
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
expires = body['expiresInSeconds']
|
|
419
|
+
return if expires.is_a?(Integer) && expires.between?(1, MAX_SIGNED_URL_SECONDS)
|
|
420
|
+
|
|
421
|
+
raise Internal.invalid('expires_in_seconds must be a whole number from 1 to 86400')
|
|
303
422
|
end
|
|
304
423
|
|
|
305
424
|
def check_mime(type)
|
data/lib/calmlens/cdn.rb
CHANGED
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
module CalmLens
|
|
4
4
|
# CDN URLs and authenticated content delivery. Reach it through {ServerClient#cdn}.
|
|
5
5
|
class CDN < Resource
|
|
6
|
-
|
|
6
|
+
RESOURCES = %w[original rendered placeholder].freeze
|
|
7
|
+
private_constant :RESOURCES
|
|
8
|
+
|
|
9
|
+
# Builds the CDN URL of an asset's rendered, original or placeholder resource. Private assets need the API key
|
|
7
10
|
# ({#serve}) or a signed URL ({Assets#create_signed_url}).
|
|
8
11
|
# @option options [String] :asset_identifier the asset ID or external ID
|
|
9
|
-
# @option options [String] :resource `
|
|
12
|
+
# @option options [String] :resource `rendered` (default), `original` or `placeholder`. Only `rendered`
|
|
13
|
+
# takes a lens or transform. A placeholder is a small blurred PNG that the CDN serves publicly for every
|
|
14
|
+
# asset whatever its visibility or moderation status; it needs no age confirmation.
|
|
10
15
|
# @option options [String] :lens a lens identifier
|
|
11
|
-
# @option options [String, Hash, Array<Hash>] :transform a selector such as `
|
|
16
|
+
# @option options [String, Hash, Array<Hash>] :transform a selector such as `width=200,fit=cover`,
|
|
12
17
|
# or structured steps: `{type: 'imageTransform', options: {width: 200}}` or an Array of them
|
|
18
|
+
# @option options [Hash] :viewer_claims viewer assertions, such as `{age_confirmed: true}`, sent as a JSON
|
|
19
|
+
# `viewerClaims` query parameter. The CDN serves an asset whose moderation status is `age-restricted` only
|
|
20
|
+
# with `age_confirmed: true`, even with a signed URL or the API key. Set it only where your app has already
|
|
21
|
+
# decided the viewer may see age-restricted content.
|
|
13
22
|
# @return [String]
|
|
14
23
|
def get_asset_url(options)
|
|
15
24
|
call(options) { |api, _request| url(api) }
|
|
@@ -23,6 +32,7 @@ module CalmLens
|
|
|
23
32
|
# not raised; check {Response#status}.
|
|
24
33
|
#
|
|
25
34
|
# @option options [String] :asset_identifier
|
|
35
|
+
# @option options [Hash] :viewer_claims see {#get_asset_url}
|
|
26
36
|
# @option options [Hash] :init `{method: 'GET' | 'HEAD', headers: {...}}`
|
|
27
37
|
# @yieldparam response [Response]
|
|
28
38
|
# @return [Response, Object]
|
|
@@ -49,15 +59,27 @@ module CalmLens
|
|
|
49
59
|
private
|
|
50
60
|
|
|
51
61
|
def url(api)
|
|
52
|
-
resource = api.fetch('resource', '
|
|
53
|
-
raise Internal.invalid('Resource must be
|
|
62
|
+
resource = api.fetch('resource', 'rendered')
|
|
63
|
+
raise Internal.invalid('Resource must be original, rendered or placeholder') unless RESOURCES.include?(resource)
|
|
64
|
+
|
|
65
|
+
transformed = !api['lens'].nil? || !api['transform'].nil?
|
|
66
|
+
raise Internal.invalid('Lenses and transforms apply only to the rendered resource') if resource == 'original' && transformed
|
|
67
|
+
raise Internal.invalid('A placeholder takes no lens, transform or ageConfirmed') if resource == 'placeholder' && transformed
|
|
54
68
|
|
|
55
69
|
url = "#{@session.cdn_base_url}/assets/#{Internal.segment(@session.project_id)}/" \
|
|
56
70
|
"#{Internal.segment(api['assetIdentifier'])}/#{resource}"
|
|
57
71
|
url += "/@#{Internal.segment(api['lens'])}" if api['lens']
|
|
58
72
|
transform = api['transform']
|
|
59
|
-
url += "
|
|
60
|
-
|
|
73
|
+
url += "/#{Transforms.path(transform)}" unless transform.nil? || transform == ''
|
|
74
|
+
|
|
75
|
+
claims = api['viewerClaims']
|
|
76
|
+
if !claims.nil? && (!claims.is_a?(Hash) || (claims.key?('ageConfirmed') && ![true, false].include?(claims['ageConfirmed'])))
|
|
77
|
+
raise Internal.invalid('Invalid viewerClaims')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
query = []
|
|
81
|
+
query << "viewerClaims=#{URI.encode_www_form_component(JSON.generate(api['viewerClaims']))}" if api['viewerClaims']
|
|
82
|
+
query.empty? ? url : "#{url}?#{query.join('&')}"
|
|
61
83
|
end
|
|
62
84
|
end
|
|
63
85
|
end
|
data/lib/calmlens/error_codes.rb
CHANGED
|
@@ -5,20 +5,44 @@
|
|
|
5
5
|
module CalmLens
|
|
6
6
|
# Error codes the CalmLens API returns, for comparing with {Error#code}.
|
|
7
7
|
module ErrorCodes
|
|
8
|
+
INVALID_VIEWER_CLAIMS = 'viewer/invalid-claims'
|
|
9
|
+
AGE_CONFIRMATION_REQUIRED = 'viewer/age-confirmation-required'
|
|
10
|
+
INVALID_VIEWER_REGION = 'viewer/invalid-region'
|
|
11
|
+
VIEWER_REGION_REQUIRED = 'viewer/region-required'
|
|
12
|
+
REGIONAL_EVIDENCE_REQUIRED = 'asset/regional-evidence-required'
|
|
13
|
+
INVALID_TYPE = 'validation/invalid-type'
|
|
14
|
+
TOO_SMALL = 'validation/too-small'
|
|
15
|
+
TOO_BIG = 'validation/too-big'
|
|
16
|
+
INVALID_FORMAT = 'validation/invalid-format'
|
|
17
|
+
INVALID_VALUE = 'validation/invalid-value'
|
|
18
|
+
UNKNOWN_FIELD = 'validation/unknown-field'
|
|
19
|
+
UNSUPPORTED_CONTENT_TYPE = 'validation/unsupported-content-type'
|
|
20
|
+
INVALID_FILTER = 'validation/invalid-filter'
|
|
21
|
+
INVALID_ORDERING_FIELD = 'validation/invalid-ordering-field'
|
|
22
|
+
ROUTE_NOT_FOUND = 'misc/route-not-found'
|
|
23
|
+
INTERNAL_ERROR = 'misc/internal-error'
|
|
24
|
+
UNAUTHENTICATED = 'auth/unauthenticated'
|
|
25
|
+
TOKEN_EXPIRED = 'auth/token-expired'
|
|
26
|
+
INVALID_TOKEN = 'auth/invalid-token'
|
|
27
|
+
SEARCH_UNAVAILABLE = 'search/unavailable'
|
|
28
|
+
INVALID_TRANSFORM = 'asset/invalid-transform'
|
|
29
|
+
INVALID_TRANSFORM_OPTIONS = 'asset/invalid-transform-options'
|
|
30
|
+
REMOTE_URL_REDIRECT_LIMIT = 'asset/remote-url-redirect-limit'
|
|
31
|
+
COPYRIGHT_CONTENT_HELD = 'copyright/content-held'
|
|
32
|
+
DOWNLOAD_PERMISSION_REQUIRED = 'asset/api-key-missing-permission'
|
|
8
33
|
INVALID_CURSOR = 'validation/invalid-cursor'
|
|
9
34
|
MISSING_TOKEN = 'auth/missing-token'
|
|
10
35
|
INVALID_API_KEY = 'auth/invalid-api-key'
|
|
11
36
|
API_KEY_PROJECT_MISMATCH = 'auth/project-mismatch'
|
|
12
37
|
API_KEY_NOT_ACCEPTED = 'auth/api-key-not-accepted'
|
|
38
|
+
PERMISSION_DENIED = 'auth/permission-denied'
|
|
13
39
|
FILE_TOO_LARGE = 'upload/file-too-large'
|
|
14
|
-
IMAGE_REQUIRES_MULTIPART_UPLOAD = 'upload/image-requires-multipart-upload'
|
|
15
40
|
MULTIPART_IMAGE_TOO_SMALL = 'upload/multipart-image-too-small'
|
|
16
41
|
MULTIPART_IMAGE_TOO_LARGE = 'upload/multipart-image-too-large'
|
|
17
42
|
IMAGE_EXCEEDED_MAX_DIMENSIONS = 'upload/image-exceeded-max-dimensions'
|
|
18
43
|
UPLOAD_LIMIT_EXCEEDED = 'upload/limit-exceeded'
|
|
19
44
|
SPEND_LIMIT_REACHED = 'upload/spend-limit-reached'
|
|
20
45
|
INVALID_UPLOAD_MIME_TYPE = 'upload/invalid-mime-type'
|
|
21
|
-
INVALID_EVENT_CURSOR = 'events/invalid-cursor'
|
|
22
46
|
INVALID_UPLOAD_PAYLOAD = 'upload/invalid-payload'
|
|
23
47
|
USE_UPLOAD_PART = 'upload/use-upload-part'
|
|
24
48
|
UPLOAD_NOT_OPEN = 'upload/not-open'
|
|
@@ -30,10 +54,19 @@ module CalmLens
|
|
|
30
54
|
IDEMPOTENT_REQUEST_IN_PROGRESS = 'idempotency/request-in-progress'
|
|
31
55
|
ASSET_NOT_FOUND = 'asset/not-found'
|
|
32
56
|
PROJECT_NOT_FOUND = 'project/not-found'
|
|
57
|
+
ASSET_REVIEW_CHANGED = 'asset/review-changed'
|
|
58
|
+
ASSET_REVIEW_NOTE_REQUIRED = 'asset/review-note-required'
|
|
33
59
|
ASSET_UPDATE_FAILED = 'asset/update-failed'
|
|
34
60
|
ASSET_SAFETY_HELD = 'asset/safety-held'
|
|
61
|
+
ASSET_PROCESSING = 'asset/processing'
|
|
62
|
+
ASSET_DECISION_NOT_ALLOWED = 'asset/decision-not-allowed'
|
|
63
|
+
ASSET_DECISION_UNCHANGED = 'asset/decision-unchanged'
|
|
35
64
|
ASSET_NOT_RETRYABLE = 'asset/not-retryable'
|
|
36
65
|
ASSET_CONTENT_UNAVAILABLE = 'asset/content-unavailable'
|
|
66
|
+
ASSET_CONTENT_PURGED = 'asset/content-purged'
|
|
67
|
+
ASSET_NOT_DECIDED = 'asset/not-decided'
|
|
68
|
+
ASSET_POLICY_CHANGED = 'asset/policy-changed'
|
|
69
|
+
ASSET_REVIEWED = 'asset/reviewed'
|
|
37
70
|
SUPPORT_TICKET_NOT_FOUND = 'support/ticket-not-found'
|
|
38
71
|
DUPLICATE_EXTERNAL_ID = 'asset/duplicate-external-id'
|
|
39
72
|
FOLDER_NOT_FOUND = 'folder/not-found'
|
|
@@ -41,6 +74,12 @@ module CalmLens
|
|
|
41
74
|
FOLDER_NOT_EMPTY = 'folder/not-empty'
|
|
42
75
|
FOLDER_PARENT_CYCLE = 'folder/parent-cycle'
|
|
43
76
|
LENS_VERSION_CONFLICT = 'lens/version-conflict'
|
|
77
|
+
LENS_IDENTIFIER_RESERVED = 'lens/identifier-reserved'
|
|
78
|
+
HASH_LIST_NOT_FOUND = 'hash-list/not-found'
|
|
79
|
+
HASH_LIST_ENTRY_NOT_FOUND = 'hash-list/entry-not-found'
|
|
80
|
+
HASH_LIST_LIMIT_REACHED = 'hash-list/limit-reached'
|
|
81
|
+
HASH_LIST_ENTRY_LIMIT_REACHED = 'hash-list/entry-limit-reached'
|
|
82
|
+
HASH_LIST_ASSET_NOT_HASHED = 'hash-list/asset-not-hashed'
|
|
44
83
|
UPLOAD_PRESET_NOT_FOUND = 'upload-preset/not-found'
|
|
45
84
|
UPLOAD_PRESET_DISABLED = 'upload-preset/disabled'
|
|
46
85
|
UPLOAD_PRESET_TOKEN_REQUIRED = 'upload-preset/token-required'
|
|
@@ -68,20 +107,44 @@ module CalmLens
|
|
|
68
107
|
|
|
69
108
|
# The HTTP status the API returns with each code.
|
|
70
109
|
STATUSES = {
|
|
110
|
+
'viewer/invalid-claims' => 400,
|
|
111
|
+
'viewer/age-confirmation-required' => 403,
|
|
112
|
+
'viewer/invalid-region' => 400,
|
|
113
|
+
'viewer/region-required' => 403,
|
|
114
|
+
'asset/regional-evidence-required' => 403,
|
|
115
|
+
'validation/invalid-type' => 400,
|
|
116
|
+
'validation/too-small' => 400,
|
|
117
|
+
'validation/too-big' => 400,
|
|
118
|
+
'validation/invalid-format' => 400,
|
|
119
|
+
'validation/invalid-value' => 400,
|
|
120
|
+
'validation/unknown-field' => 400,
|
|
121
|
+
'validation/unsupported-content-type' => 415,
|
|
122
|
+
'validation/invalid-filter' => 400,
|
|
123
|
+
'validation/invalid-ordering-field' => 400,
|
|
124
|
+
'misc/route-not-found' => 404,
|
|
125
|
+
'misc/internal-error' => 500,
|
|
126
|
+
'auth/unauthenticated' => 401,
|
|
127
|
+
'auth/token-expired' => 401,
|
|
128
|
+
'auth/invalid-token' => 401,
|
|
129
|
+
'search/unavailable' => 503,
|
|
130
|
+
'asset/invalid-transform' => 400,
|
|
131
|
+
'asset/invalid-transform-options' => 400,
|
|
132
|
+
'asset/remote-url-redirect-limit' => 400,
|
|
133
|
+
'copyright/content-held' => 409,
|
|
134
|
+
'asset/api-key-missing-permission' => 403,
|
|
71
135
|
'validation/invalid-cursor' => 400,
|
|
72
136
|
'auth/missing-token' => 401,
|
|
73
137
|
'auth/invalid-api-key' => 401,
|
|
74
138
|
'auth/project-mismatch' => 403,
|
|
75
139
|
'auth/api-key-not-accepted' => 403,
|
|
140
|
+
'auth/permission-denied' => 403,
|
|
76
141
|
'upload/file-too-large' => 400,
|
|
77
|
-
'upload/image-requires-multipart-upload' => 400,
|
|
78
142
|
'upload/multipart-image-too-small' => 400,
|
|
79
143
|
'upload/multipart-image-too-large' => 400,
|
|
80
144
|
'upload/image-exceeded-max-dimensions' => 400,
|
|
81
145
|
'upload/limit-exceeded' => 403,
|
|
82
146
|
'upload/spend-limit-reached' => 402,
|
|
83
147
|
'upload/invalid-mime-type' => 400,
|
|
84
|
-
'events/invalid-cursor' => 400,
|
|
85
148
|
'upload/invalid-payload' => 400,
|
|
86
149
|
'upload/use-upload-part' => 409,
|
|
87
150
|
'upload/not-open' => 409,
|
|
@@ -93,10 +156,19 @@ module CalmLens
|
|
|
93
156
|
'idempotency/request-in-progress' => 409,
|
|
94
157
|
'asset/not-found' => 404,
|
|
95
158
|
'project/not-found' => 404,
|
|
159
|
+
'asset/review-changed' => 409,
|
|
160
|
+
'asset/review-note-required' => 400,
|
|
96
161
|
'asset/update-failed' => 500,
|
|
97
162
|
'asset/safety-held' => 423,
|
|
163
|
+
'asset/processing' => 409,
|
|
164
|
+
'asset/decision-not-allowed' => 409,
|
|
165
|
+
'asset/decision-unchanged' => 409,
|
|
98
166
|
'asset/not-retryable' => 409,
|
|
99
167
|
'asset/content-unavailable' => 409,
|
|
168
|
+
'asset/content-purged' => 409,
|
|
169
|
+
'asset/not-decided' => 409,
|
|
170
|
+
'asset/policy-changed' => 409,
|
|
171
|
+
'asset/reviewed' => 409,
|
|
100
172
|
'support/ticket-not-found' => 404,
|
|
101
173
|
'asset/duplicate-external-id' => 409,
|
|
102
174
|
'folder/not-found' => 404,
|
|
@@ -104,6 +176,12 @@ module CalmLens
|
|
|
104
176
|
'folder/not-empty' => 409,
|
|
105
177
|
'folder/parent-cycle' => 409,
|
|
106
178
|
'lens/version-conflict' => 409,
|
|
179
|
+
'lens/identifier-reserved' => 409,
|
|
180
|
+
'hash-list/not-found' => 404,
|
|
181
|
+
'hash-list/entry-not-found' => 404,
|
|
182
|
+
'hash-list/limit-reached' => 409,
|
|
183
|
+
'hash-list/entry-limit-reached' => 409,
|
|
184
|
+
'hash-list/asset-not-hashed' => 409,
|
|
107
185
|
'upload-preset/not-found' => 404,
|
|
108
186
|
'upload-preset/disabled' => 403,
|
|
109
187
|
'upload-preset/token-required' => 401,
|