calmlens 0.1.0 → 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.
@@ -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
- # Moderation statuses that carry a decision; {#wait_for_decision} returns once an asset has one,
18
- # or once its processing status is in {FINISHED_PROCESSING_STATUSES}.
19
- DECIDED_MODERATION_STATUSES = %w[approved rejected].freeze
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==approved`
39
- # @option options [Array<Hash>] :ordering `[{field: 'createdAt', direction: 'DESC'}]`
40
- # @return [Hash] `{'items', 'total', 'hasMore', 'cursor', 'pageSize'}`
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/page'), query: api, request_options: request)
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 `approved` or `rejected`, or its
92
- # `processing.status` is `failed`, and returns the asset. It retrieves the asset, waiting 0.5
93
- # seconds before the second check and 1.5 times longer each time after that, up to 5 seconds.
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 `'processingStatus'` and `'moderationStatus'`.
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 `processing.status` is `failed` again.
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 moderation report are kept.
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 `content` (default) or `preview`
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
- body = without(api, 'assetId')
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', "/assets/#{Internal.segment(api['assetId'])}/signed-url",
177
- body: { 'resource' => 'content', 'expiresInSeconds' => 900 }.merge(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.approved` and `asset.rejected` events or retrieve the asset later.
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; publish only when `moderation.status` is `approved` and
205
- # `moderation.action` is nil.
206
- def moderate(options)
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 moderate must be a non-empty string', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
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 {#moderate} and `part:`.
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; `processing.status` stays `uploading` until the final part.
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
- headers: { 'Content-Type' => 'application/octet-stream', 'X-CalmLens-Upload' => 'part' })
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 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
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('processing', 'status')
251
- moderation = asset.dig('moderation', 'status')
252
- return asset if DECIDED_MODERATION_STATUSES.include?(moderation) || FINISHED_PROCESSING_STATUSES.include?(processing)
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 - Process.clock_gettime(Process::CLOCK_MONOTONIC)
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'], 'processingStatus' => processing,
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 * 1.5, DECISION_POLL_MAX_DELAY].min
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, idempotent: true,
276
- request_options: request_options)
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
- total = options['totalSizeInBytes']
293
- unless total.nil? || (total.is_a?(Integer) && total.positive?)
294
- raise Internal.invalid('totalSizeInBytes must be a positive Integer', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
295
- end
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
- check_image_size(total || body.size, total || content.fetch('isFinal', false)) if mime.start_with?('image/')
298
- query.merge!('partNumber' => 1, 'isFinal' => content.fetch('isFinal', false), 'totalSizeInBytes' => total)
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
- request('POST', project('assets'),
301
- query: query, raw: body, idempotent: true, request_options: request_options,
302
- headers: { 'Content-Type' => content['type'], 'X-CalmLens-Upload' => multipart ? 'part' : 'content' })
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
- # Builds the CDN URL of an asset's content or preview. Private assets need the API key
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 `content` (default) or `preview`
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 `imageTransform[width=200]`,
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', 'content')
53
- raise Internal.invalid('Resource must be content or preview') unless %w[content preview].include?(resource)
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 += "#{api['lens'] ? ',' : '/'}#{Transforms.path(transform)}" unless transform.nil? || transform == ''
60
- url
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
@@ -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,