calmlens 0.20.0 → 0.22.0

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.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module CalmLens
4
6
  # An upload payload: a binary String, or an IO streamed with an exact byte count (or, when its
5
7
  # length is unknown, until it ends, sent with chunked transfer encoding).
@@ -98,6 +100,20 @@ module CalmLens
98
100
  UploadBody.new(@source.slice(offset, length))
99
101
  end
100
102
 
103
+ # This body as the `file` part of an upload form, after an `options` part holding
104
+ # `options_json`: `multipart/form-data` with a random boundary. A stream stays a stream, read
105
+ # as the form is sent, and the form can be sent again whenever this body can.
106
+ # @return [Array(UploadBody, String)] the form and its Content-Type.
107
+ def to_form(options_json, type)
108
+ boundary = "calmlens-#{SecureRandom.uuid}"
109
+ head = "--#{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n".b +
110
+ options_json.b + "\r\n--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"\r\n".b +
111
+ "Content-Type: #{type.delete("\r\n")}\r\n\r\n".b
112
+ tail = "\r\n--#{boundary}--\r\n".b
113
+ source = @source.is_a?(String) ? head + @source + tail : FormReader.new(head, @source, tail)
114
+ [UploadBody.new(source), "multipart/form-data; boundary=#{boundary}"]
115
+ end
116
+
101
117
  # The bytes, read fully. Used only by the offline mock.
102
118
  def read_all
103
119
  return @source if @source.is_a?(String)
@@ -111,9 +127,12 @@ module CalmLens
111
127
  class IOReader
112
128
  attr_reader :size
113
129
 
114
- def initialize(io, size)
130
+ # @param bounded [Boolean] the stream goes on after `size` bytes (a slice of a larger file), so
131
+ # reading stops there without checking for more.
132
+ def initialize(io, size, bounded: false)
115
133
  @io = io
116
134
  @size = size
135
+ @bounded = bounded
117
136
  @remaining = size
118
137
  @start = (io.pos if io.respond_to?(:pos) && io.respond_to?(:seek))
119
138
  rescue IOError, SystemCallError
@@ -125,7 +144,7 @@ module CalmLens
125
144
  # A reader of `length` bytes from `offset` of this rewindable stream, positioned there.
126
145
  def slice(offset, length)
127
146
  @io.seek(@start + offset)
128
- IOReader.new(@io, length)
147
+ IOReader.new(@io, length, bounded: true)
129
148
  end
130
149
 
131
150
  def rewind
@@ -138,6 +157,8 @@ module CalmLens
138
157
  return read_unsized(length, buffer) if @size.nil?
139
158
 
140
159
  if @remaining.zero?
160
+ return length.nil? ? ''.b : buffer&.clear && nil if @bounded
161
+
141
162
  extra = @io.read(1)
142
163
  unless extra.nil? || extra.empty?
143
164
  raise Internal.invalid('The stream holds more bytes than sizeInBytes',
@@ -169,6 +190,68 @@ module CalmLens
169
190
  read(length, buffer) || raise(EOFError)
170
191
  end
171
192
  end
193
+
194
+ # An upload form around a streamed file: the form's head, the file's bytes as an {IOReader}
195
+ # reads them, then its closing boundary.
196
+ class FormReader
197
+ def initialize(head, file, tail)
198
+ @head = head
199
+ @file = file
200
+ @tail = tail
201
+ @size = file.size && (head.bytesize + file.size + tail.bytesize)
202
+ rewind_pieces
203
+ end
204
+
205
+ attr_reader :size
206
+
207
+ def rewindable? = @file.rewindable?
208
+
209
+ def rewind
210
+ @file.rewind
211
+ rewind_pieces
212
+ end
213
+
214
+ # Reads like IO#read: everything left without a length, else up to `length` bytes, nil at the end.
215
+ def read(length = nil, buffer = nil)
216
+ return (buffer ? buffer.replace(''.b) : ''.b) if length&.zero?
217
+
218
+ output = ''.b
219
+ until @pieces.empty? || (length && output.bytesize >= length)
220
+ chunk = read_piece(length && (length - output.bytesize))
221
+ chunk.nil? || chunk.empty? ? next_piece : output << chunk
222
+ end
223
+ return buffer&.clear && nil if output.empty? && length
224
+
225
+ buffer ? buffer.replace(output) : output
226
+ end
227
+
228
+ def readpartial(length, buffer = nil)
229
+ read(length, buffer) || raise(EOFError)
230
+ end
231
+
232
+ private
233
+
234
+ def rewind_pieces
235
+ @pieces = %i[head file tail]
236
+ @offset = 0
237
+ end
238
+
239
+ def next_piece
240
+ @pieces.shift
241
+ @offset = 0
242
+ end
243
+
244
+ # Up to `length` bytes (all, without one) of the current piece; empty or nil once it is used up.
245
+ def read_piece(length)
246
+ piece = @pieces.first
247
+ return @file.read(length) if piece == :file
248
+
249
+ text = piece == :head ? @head : @tail
250
+ chunk = text.byteslice(@offset, length || text.bytesize).to_s
251
+ @offset += chunk.bytesize
252
+ chunk
253
+ end
254
+ end
172
255
  end
173
256
  private_constant :UploadBody
174
257
  end
@@ -3,7 +3,7 @@
3
3
  # Generated by packages/sdk-contract/generate.ts from packages/shared/src/UploadMimeTypes.ts; do not edit.
4
4
  # After lower-casing and dropping parameters, a type in REJECTED is refused; otherwise one
5
5
  # that starts with a PREFIXES entry, ends with a SUFFIXES entry or is in TYPES is accepted,
6
- # and any other type is refused with upload/invalid-mime-type.
6
+ # and any other type is refused with upload/unsupported-type.
7
7
 
8
8
  module CalmLens
9
9
  module Internal
@@ -32,6 +32,14 @@ module CalmLens
32
32
  'application/msword',
33
33
  'application/rtf',
34
34
  'application/vnd.oasis.opendocument.text',
35
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
36
+ 'application/vnd.ms-excel.sheet.macroenabled.12',
37
+ 'application/vnd.ms-excel',
38
+ 'application/vnd.oasis.opendocument.spreadsheet',
39
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
40
+ 'application/vnd.ms-powerpoint.presentation.macroenabled.12',
41
+ 'application/vnd.ms-powerpoint',
42
+ 'application/vnd.oasis.opendocument.presentation',
35
43
  'application/javascript',
36
44
  'application/json',
37
45
  'application/x-javascript',
@@ -26,7 +26,7 @@ module CalmLens
26
26
  # Signs a single-use token. Hand it to the browser as its bearer token; give each upload its own.
27
27
  # @option options [String] :upload_preset_id the preset the token may upload through
28
28
  # @option options [String] :external_id the external ID the uploaded asset gets (not empty)
29
- # @option options [Integer] :expires_in_seconds lifetime, 1 to 3600 (default 600)
29
+ # @option options [Integer] :expires_in lifetime in seconds, 1 to 3600 (default 600)
30
30
  # @return [Hash] `{'token' => String, 'expiresAt' => Integer}` (milliseconds since the Unix epoch)
31
31
  def sign(options)
32
32
  call(options, __method__) { UploadTokens.sign_at(@api_key, options, now: (Time.now.to_f * 1000).to_i, nonce: SecureRandom.uuid) }
@@ -39,9 +39,9 @@ module CalmLens
39
39
  api, = Internal.options(options)
40
40
  preset_id = api['uploadPresetId']
41
41
  Internal.segment(preset_id)
42
- ttl = api.fetch('expiresInSeconds', DEFAULT_TTL_SECONDS)
42
+ ttl = api.fetch('expiresIn', DEFAULT_TTL_SECONDS)
43
43
  unless ttl.is_a?(Integer) && ttl.between?(1, MAX_TTL_SECONDS)
44
- raise Internal.invalid("expiresInSeconds must be a whole number from 1 to #{MAX_TTL_SECONDS}")
44
+ raise Internal.invalid("expiresIn must be a whole number from 1 to #{MAX_TTL_SECONDS}")
45
45
  end
46
46
 
47
47
  external_id = api['externalId']
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CalmLens
4
4
  # The gem version. The gemspec and the User-Agent header read it from here.
5
- VERSION = '0.20.0'
5
+ VERSION = '0.22.0'
6
6
  end
@@ -15,25 +15,34 @@ module CalmLens
15
15
  WEBHOOK_SIGNATURE_TOLERANCE_SECONDS = 300
16
16
 
17
17
  # A verified webhook delivery. The body has the shape of an {Events#list} item:
18
- # `{id, projectId, type, severity, title, description, assetId, asset, facts, createdAt}`.
18
+ # `{id, projectId, actor, type, severity, title, description, assetId, assetSequence, asset, facts, createdAt}`.
19
19
  # Fields CalmLens adds later are kept in {#payload}, as are unknown asset and fact fields.
20
20
  #
21
21
  # @!attribute id [String] the event ID.
22
22
  # @!attribute project_id [String]
23
+ # @!attribute actor [Hash, nil] who caused the event: `{'type', 'id'}`, with `type` `user`, `api-key`,
24
+ # `automated` or `system`, and `id` the user's or API key's ID, or nil when it was not recorded.
23
25
  # @!attribute type [String] such as `asset.allowed`. New types may appear: ignore unknown ones.
24
26
  # @!attribute severity [String, nil] `low` or `high`.
25
27
  # @!attribute title [String, nil]
26
28
  # @!attribute description [String, nil]
27
29
  # @!attribute asset_id [String, nil]
30
+ # @!attribute asset_sequence [Integer, nil] increases with each event about the same asset; nil for
31
+ # events not about an asset. Deliveries can arrive out of order: see {#newer_than?}.
28
32
  # @!attribute asset [Hash, nil] the asset's status when the event was created.
29
33
  # @!attribute facts [Array<Hash>] `{'key', 'value', 'url'?}` entries registered for the event type.
30
34
  # @!attribute created_at [Integer] milliseconds since the epoch.
31
35
  # @!attribute payload [Hash{String => Object}] the delivery body as received, with every field.
32
- WebhookEvent = Struct.new(:id, :project_id, :type, :severity, :title, :description, :asset_id, :asset, :facts,
33
- :created_at, :payload, keyword_init: true) do
36
+ WebhookEvent = Struct.new(:id, :project_id, :actor, :type, :severity, :title, :description, :asset_id, :asset_sequence, :asset,
37
+ :facts, :created_at, :payload, keyword_init: true) do
34
38
  # @return [Boolean] true for a test send from the portal or CLI.
35
39
  def test? = Webhooks.test_event?(self)
36
40
 
41
+ # @param last_sequence [Integer, nil] the highest `assetSequence` applied for this event's asset
42
+ # @return [Boolean] whether to apply this event after it.
43
+ # @see Webhooks.newer_asset_event?
44
+ def newer_than?(last_sequence) = Webhooks.newer_asset_event?(self, last_sequence)
45
+
37
46
  # @return [Hash{String => Object}] the delivery body as received, with every field.
38
47
  def to_payload = payload
39
48
  end
@@ -79,16 +88,34 @@ module CalmLens
79
88
  facts.is_a?(Array) && facts.any? { |fact| fact.is_a?(Hash) && fact['key'] == 'test' && fact['value'] == true }
80
89
  end
81
90
 
91
+ # Whether to apply `event` after `last_sequence`, the highest `assetSequence` you have applied
92
+ # for its asset (nil when none). Deliveries can arrive out of order: an event about an asset is
93
+ # newer only with a higher `assetSequence`, so a retried or repeated delivery of an earlier one
94
+ # returns false. Events that aren't about an asset have no sequence and are always new. Store
95
+ # the highest `assetSequence` applied per `assetId` and ignore lower ones.
96
+ # @param event [WebhookEvent, Hash] a parsed delivery, or a delivery body with string keys
97
+ # @param last_sequence [Integer, nil]
98
+ # @return [Boolean]
99
+ def newer_asset_event?(event, last_sequence)
100
+ sequence = event.is_a?(Hash) ? event['assetSequence'] : event.asset_sequence
101
+ sequence.nil? || last_sequence.nil? || sequence > last_sequence
102
+ end
103
+
82
104
  # Converts an event from {Events#list} to the JSON body of its webhook delivery, which has the
83
- # same fields: `description`, `assetId` and `asset` are nil when absent.
105
+ # same fields: `description`, `assetId`, `assetSequence` and `asset` are nil when absent, and
106
+ # `actor` is left out when the event has none.
84
107
  # @param event [Hash] an event with string keys, as {Events#list} returns it
85
- # @return [Hash] `{ 'id', 'projectId', 'type', 'severity', 'title', 'description', 'assetId', 'asset', 'facts', 'createdAt' }`
108
+ # @return [Hash] `{ 'id', 'projectId', 'actor', 'type', 'severity', 'title', 'description', 'assetId',
109
+ # 'assetSequence', 'asset', 'facts', 'createdAt' }`
86
110
  def to_payload(event)
87
111
  raise Internal.invalid('event must be a Hash') unless event.is_a?(Hash)
88
112
 
89
- { 'id' => event['id'], 'projectId' => event['projectId'], 'type' => event['type'], 'severity' => event['severity'],
90
- 'title' => event['title'], 'description' => event['description'], 'assetId' => event['assetId'],
91
- 'asset' => event['asset'], 'facts' => event['facts'], 'createdAt' => event['createdAt'] }
113
+ payload = { 'id' => event['id'], 'projectId' => event['projectId'] }
114
+ payload['actor'] = event['actor'] if event.key?('actor')
115
+ payload.merge('type' => event['type'], 'severity' => event['severity'], 'title' => event['title'],
116
+ 'description' => event['description'], 'assetId' => event['assetId'],
117
+ 'assetSequence' => event['assetSequence'], 'asset' => event['asset'], 'facts' => event['facts'],
118
+ 'createdAt' => event['createdAt'])
92
119
  end
93
120
 
94
121
  # Checks a delivery's signature and freshness in constant time. Pass the `signature` and
@@ -149,9 +176,10 @@ module CalmLens
149
176
  body = JSON.parse(payload.dup.force_encoding(Encoding::UTF_8))
150
177
  raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_PAYLOAD, 'Webhook body is not a CalmLens event') unless event?(body)
151
178
 
152
- WebhookEvent.new(id: body['id'], project_id: body['projectId'], type: body['type'], severity: body['severity'],
179
+ WebhookEvent.new(id: body['id'], project_id: body['projectId'], actor: body['actor'], type: body['type'], severity: body['severity'],
153
180
  title: body['title'], description: body['description'], asset_id: body['assetId'],
154
- asset: body['asset'], facts: body.fetch('facts', []), created_at: body['createdAt'], payload: body)
181
+ asset_sequence: body['assetSequence'], asset: body['asset'], facts: body.fetch('facts', []),
182
+ created_at: body['createdAt'], payload: body)
155
183
  rescue JSON::ParserError, EncodingError => e
156
184
  raise WebhookVerificationError.new(SdkErrorCodes::INVALID_WEBHOOK_PAYLOAD, 'Webhook body is not JSON'), cause: e
157
185
  end
@@ -163,9 +191,17 @@ module CalmLens
163
191
  body['projectId'].is_a?(String) && body['createdAt'].is_a?(Numeric)
164
192
 
165
193
  optional = ->(key, *types) { !body.key?(key) || types.any? { |type| type.nil? ? body[key].nil? : body[key].is_a?(type) } }
166
- asset = body['asset']
167
194
  optional.call('severity', String) && optional.call('title', String) && optional.call('description', String, nil) &&
168
- optional.call('assetId', String, nil) && (asset.nil? || (asset.is_a?(Hash) && asset['id'].is_a?(String))) &&
195
+ optional.call('assetId', String, nil) && optional.call('assetSequence', Numeric, nil) && nested?(body)
196
+ end
197
+
198
+ # The nested fields, when present: `actor` is `{type, id}` with a String type and a String or null
199
+ # ID, `asset` has a String `id` and `facts` lists `{key, value, url?}` entries.
200
+ def nested?(body)
201
+ actor = body.fetch('actor', { 'type' => '' })
202
+ asset = body['asset']
203
+ actor.is_a?(Hash) && actor['type'].is_a?(String) && (actor['id'].nil? || actor['id'].is_a?(String)) &&
204
+ (asset.nil? || (asset.is_a?(Hash) && asset['id'].is_a?(String))) &&
169
205
  (!body.key?('facts') || (body['facts'].is_a?(Array) && body['facts'].all? { |fact| fact?(fact) }))
170
206
  end
171
207
 
@@ -191,7 +227,7 @@ module CalmLens
191
227
  raise Internal.invalid("Webhook payload must be the raw body String, not a #{payload.class}. " \
192
228
  'Read request.raw_post (Rails) or request.body.read (Rack, Sinatra) before parsing.')
193
229
  end
194
- private_class_method :decode_event, :event?, :fact?, :header, :raw_body
230
+ private_class_method :decode_event, :event?, :nested?, :fact?, :header, :raw_body
195
231
  end
196
232
 
197
233
  # @see Webhooks.signature_header
@@ -214,4 +250,13 @@ module CalmLens
214
250
 
215
251
  # @see Webhooks.test_event?
216
252
  def self.test_webhook_event?(...) = Webhooks.test_event?(...)
253
+
254
+ # @see Webhooks.newer_asset_event?
255
+ def self.newer_asset_event?(...) = Webhooks.newer_asset_event?(...)
256
+
257
+ # @see EventFacts.value
258
+ def self.get_event_fact(...) = EventFacts.value(...)
259
+
260
+ # @see EventFacts.values
261
+ def self.get_event_facts(...) = EventFacts.values(...)
217
262
  end
data/lib/calmlens.rb CHANGED
@@ -29,5 +29,6 @@ require_relative 'calmlens/upload_tokens'
29
29
  require_relative 'calmlens/events'
30
30
  require_relative 'calmlens/cdn'
31
31
  require_relative 'calmlens/lenses'
32
+ require_relative 'calmlens/project_configuration'
32
33
  require_relative 'calmlens/server_client'
33
34
  require_relative 'calmlens/webhooks'
data/sig/calmlens.rbs CHANGED
@@ -31,6 +31,7 @@ module CalmLens
31
31
  ?headers: Hash[String, String], ?request_id: String?) -> void
32
32
  # Whole seconds from the `Retry-After` header.
33
33
  def retry_after: () -> Integer?
34
+ def local?: () -> bool
34
35
  def not_found?: () -> bool
35
36
  def retryable?: () -> bool
36
37
  def self.build: (String code, ?String? message, ?status: Integer?, ?details: Hash[String, json_value]?,
@@ -121,6 +122,10 @@ module CalmLens
121
122
  attr_reader upload_tokens: UploadTokens
122
123
  attr_reader events: Events
123
124
  attr_reader cdn: CDN
125
+ attr_reader lenses: Lenses
126
+ attr_reader upload_presets: UploadPresets
127
+ attr_reader project_settings: ProjectSettings
128
+ attr_reader metadata_fields: MetadataFields
124
129
  attr_reader project_id: String
125
130
  attr_reader base_url: String
126
131
  attr_reader cdn_base_url: String
@@ -148,16 +153,12 @@ module CalmLens
148
153
  end
149
154
 
150
155
  class Assets < Resource
151
- # The moderation status before a decision.
152
- PENDING_MODERATION_STATUS: String
153
- # Processing statuses after which no decision arrives without a retry.
154
- FINISHED_PROCESSING_STATUSES: Array[String]
156
+ # The classification statuses of a settled asset; wait_for_decision returns at the first.
157
+ SETTLED_STATUSES: Array[String]
155
158
  DECISION_POLL_FIRST_DELAY: Float
156
159
  DECISION_POLL_FACTOR: Float
157
160
  DECISION_POLL_MAX_DELAY: Float
158
161
  DEFAULT_DECISION_TIMEOUT: Integer
159
- DEFAULT_SIGNED_URL_SECONDS: Integer
160
- MAX_SIGNED_URL_SECONDS: Integer
161
162
  BATCH_OPERATIONS: Array[asset_batch_operation]
162
163
  MAX_BATCH_ASSETS: Integer
163
164
 
@@ -168,24 +169,30 @@ module CalmLens
168
169
  def list_children: (list_asset_children_options options) -> asset_page
169
170
  def each_child: (list_asset_children_options options) { (asset) -> void } -> nil
170
171
  | (list_asset_children_options options) -> Enumerator[asset, nil]
172
+ def list_timeline: (list_asset_timeline_options options) -> asset_timeline_page
173
+ def each_timeline_segment: (list_asset_timeline_options options) { (media_segment) -> void } -> nil
174
+ | (list_asset_timeline_options options) -> Enumerator[media_segment, nil]
171
175
  def retrieve: (asset_id_options options) -> asset
172
176
  def wait_for_decision: (wait_for_decision_options options) -> asset
173
177
  def update: (update_asset_options options) -> asset
174
178
  def retry: (asset_id_options options) -> asset
175
179
  def decide: (decide_asset_options options) -> asset
176
180
  def explain: (explain_asset_options options) -> asset
177
- def delete_content: (asset_id_options options) -> asset
181
+ def remove_content: (asset_id_options options) -> asset
178
182
  def delete: (asset_id_options options) -> nil
179
183
  def batch: (batch_assets_options options) -> batch_assets_response
180
184
  def move: (move_asset_options options) -> asset
181
- def create_signed_url: (create_signed_url_options options) -> signed_url
182
185
  def create: (upload_options options) -> asset
183
186
  def classify: (upload_options options) -> asset
184
187
  def upload_part: (upload_part_options options) -> asset
188
+ # Waits between decision checks; a mock client's waits take no real time.
189
+ private def sleep: (Numeric seconds) -> void
185
190
  end
186
191
 
187
192
  class Folders < Resource
188
193
  def list: (?list_folders_options options) -> folder_page
194
+ def each: (?list_folders_options options) { (folder) -> void } -> nil
195
+ | (?list_folders_options options) -> Enumerator[folder, nil]
189
196
  def create: (create_folder_options options) -> folder
190
197
  def retrieve: (folder_id_options options) -> folder
191
198
  def update: (update_folder_options options) -> folder
@@ -195,26 +202,64 @@ module CalmLens
195
202
 
196
203
  class HashLists < Resource
197
204
  def list: (?list_hash_lists_options options) -> hash_list_page
205
+ def each: (?list_hash_lists_options options) { (hash_list) -> void } -> nil
206
+ | (?list_hash_lists_options options) -> Enumerator[hash_list, nil]
198
207
  def retrieve: (hash_list_id_options options) -> hash_list
199
208
  def create: (create_hash_list_options options) -> hash_list
200
209
  def update: (update_hash_list_options options) -> hash_list
201
210
  def delete: (hash_list_id_options options) -> nil
202
211
  def list_entries: (list_hash_list_entries_options options) -> hash_list_entry_page
212
+ def each_entry: (list_hash_list_entries_options options) { (hash_list_entry) -> void } -> nil
213
+ | (list_hash_list_entries_options options) -> Enumerator[hash_list_entry, nil]
203
214
  def add_entry: (add_hash_list_entry_options options) -> hash_list_entry
204
215
  def remove_entry: (hash_list_entry_id_options options) -> nil
205
216
  end
206
217
 
207
218
  class Policies < Resource
208
- def list: (?list_policies_options options) -> policy_list
219
+ def list: (?list_policies_options options) -> policy_page
220
+ def each: (?list_policies_options options) { (policy) -> void } -> nil
221
+ | (?list_policies_options options) -> Enumerator[policy, nil]
209
222
  def retrieve: (policy_id_options options) -> policy
223
+ # The policy the project moderates with.
224
+ def retrieve_assigned: (?request_only_options options) -> policy
210
225
  def create: (create_policy_options options) -> policy
211
226
  def update: (update_policy_options options) -> policy
212
- def apply_preset: (apply_policy_preset_options options) -> policy
227
+ def reset: (restore_policy_options options) -> policy
228
+ def upgrade: (restore_policy_options options) -> policy
213
229
  def set_automatic_updates: (set_policy_automatic_updates_options options) -> policy
214
230
  def delete: (policy_id_options options) -> nil
215
231
  def assign: (assign_policy_options options) -> policy
216
232
  end
217
233
 
234
+ class Lenses < Resource
235
+ def list: (?list_lenses_options options) -> lens_page
236
+ def each: (?list_lenses_options options) { (lens) -> void } -> nil
237
+ | (?list_lenses_options options) -> Enumerator[lens, nil]
238
+ def create: (create_lens_options options) -> lens
239
+ def retrieve: (lens_id_options options) -> lens
240
+ def update: (update_lens_options options) -> lens
241
+ def delete: (lens_id_options options) -> nil
242
+ end
243
+
244
+ class UploadPresets < Resource
245
+ def list: (?list_upload_presets_options options) -> upload_preset_page
246
+ def each: (?list_upload_presets_options options) { (upload_preset) -> void } -> nil
247
+ | (?list_upload_presets_options options) -> Enumerator[upload_preset, nil]
248
+ def ensure: (ensure_upload_presets_options options) -> ensure_upload_presets_response
249
+ def delete: (upload_preset_id_options options) -> nil
250
+ end
251
+
252
+ class ProjectSettings < Resource
253
+ def get: (?request_only_options options) -> project_settings_resource
254
+ def update: (update_project_settings_options options) -> project_settings_resource
255
+ end
256
+
257
+ class MetadataFields < Resource
258
+ def list: (?request_only_options options) -> metadata_fields_resource
259
+ def ensure: (ensure_metadata_fields_options options) -> metadata_fields_resource
260
+ def delete: (delete_metadata_field_options options) -> nil
261
+ end
262
+
218
263
  class UploadTokens < Resource
219
264
  TOKEN_PREFIX: String
220
265
  SIGNING_CONTEXT: String
@@ -238,7 +283,19 @@ module CalmLens
238
283
  end
239
284
 
240
285
  class CDN < Resource
286
+ # Every CDN resource beneath an asset URL.
287
+ RESOURCES: Array[cdn_resource]
288
+ # A signed asset URL's lifetime in seconds when `expires_in` is omitted.
289
+ DEFAULT_SIGNED_URL_EXPIRES_IN: Integer
290
+ # The longest signed asset URL lifetime, in seconds.
291
+ MAX_SIGNED_URL_EXPIRES_IN: Integer
292
+ # The start of the asset segment that names an asset by its external ID.
293
+ EXTERNAL_ID_PREFIX: String
294
+ # The ISO 3166-1 alpha-2 countries a viewer's `region` can be.
295
+ VIEWER_COUNTRIES: Array[String]
241
296
  def get_asset_url: (asset_url_options options) -> String
297
+ def sign_asset_url: (sign_asset_url_options options) -> signed_asset_url
298
+ def get_asset_info: (String url, ?viewer_claims: viewer_claims?) -> asset_info
242
299
  def serve: (serve_options options) -> Response
243
300
  | [T] (serve_options options) { (Response) -> T } -> T
244
301
  end
@@ -247,12 +304,16 @@ module CalmLens
247
304
  class WebhookEvent < Struct[untyped]
248
305
  attr_accessor id: String
249
306
  attr_accessor project_id: String
307
+ # nil for a delivery without one.
308
+ attr_accessor actor: actor?
250
309
  # New types may appear: ignore unknown ones.
251
310
  attr_accessor type: event_type | String
252
311
  attr_accessor severity: event_severity?
253
312
  attr_accessor title: String?
254
313
  attr_accessor description: String?
255
314
  attr_accessor asset_id: String?
315
+ # Increases with each event about the same asset; nil for events not about an asset.
316
+ attr_accessor asset_sequence: Integer?
256
317
  attr_accessor asset: event_asset?
257
318
  attr_accessor facts: Array[event_fact]
258
319
  # Milliseconds since the Unix epoch.
@@ -260,9 +321,10 @@ module CalmLens
260
321
  # The delivery body as received, with every field.
261
322
  attr_accessor payload: Hash[String, json_value]
262
323
  def initialize: (?id: String, ?project_id: String, ?type: event_type | String, ?severity: event_severity?, ?title: String?,
263
- ?description: String?, ?asset_id: String?, ?asset: event_asset?, ?facts: Array[event_fact],
324
+ ?description: String?, ?asset_id: String?, ?asset_sequence: Integer?, ?asset: event_asset?, ?facts: Array[event_fact],
264
325
  ?created_at: Integer, ?payload: Hash[String, json_value]) -> void
265
326
  def test?: () -> bool
327
+ def newer_than?: (Integer? last_sequence) -> bool
266
328
  # The delivery body as received, with every field.
267
329
  def to_payload: () -> Hash[String, json_value]
268
330
  end
@@ -286,6 +348,16 @@ module CalmLens
286
348
  def self.parse_event: (payload: String, secret: String, ?signature: String?, ?timestamp: String?, ?headers: webhook_headers?,
287
349
  ?tolerance_seconds: Integer, ?now: number) -> WebhookEvent
288
350
  def self.test_event?: (WebhookEvent | Hash[String, untyped] event) -> bool
351
+ def self.newer_asset_event?: (WebhookEvent | Hash[String, untyped] event, Integer? last_sequence) -> bool
352
+ # The value of an event's fact by key (the first, when the event repeats it), or nil without one.
353
+ def self.get_event_fact: (WebhookEvent | Hash[String, untyped] event, String key) -> event_fact_value
354
+ # An event's facts as values by key; a repeated key keeps its first value.
355
+ def self.get_event_facts: (WebhookEvent | Hash[String, untyped] event) -> Hash[String, event_fact_value]
356
+ end
357
+
358
+ module EventFacts
359
+ def self.value: (WebhookEvent | Hash[String, untyped] event, String key) -> event_fact_value
360
+ def self.values: (WebhookEvent | Hash[String, untyped] event) -> Hash[String, event_fact_value]
289
361
  end
290
362
 
291
363
  def self.create_webhook_signature_header: (payload: String, timestamp: String | Integer, secret: String) -> String
@@ -298,6 +370,11 @@ module CalmLens
298
370
  def self.parse_webhook_event: (payload: String, secret: String, ?signature: String?, ?timestamp: String?,
299
371
  ?headers: webhook_headers?, ?tolerance_seconds: Integer, ?now: number) -> WebhookEvent
300
372
  def self.test_webhook_event?: (WebhookEvent | Hash[String, untyped] event) -> bool
373
+ def self.newer_asset_event?: (WebhookEvent | Hash[String, untyped] event, Integer? last_sequence) -> bool
374
+ # The value of an event's fact by key (the first, when the event repeats it), or nil without one.
375
+ def self.get_event_fact: (WebhookEvent | Hash[String, untyped] event, String key) -> event_fact_value
376
+ # An event's facts as values by key; a repeated key keeps its first value.
377
+ def self.get_event_facts: (WebhookEvent | Hash[String, untyped] event) -> Hash[String, event_fact_value]
301
378
 
302
379
  # require 'calmlens/testing'
303
380
  module Testing
@@ -325,6 +402,8 @@ module CalmLens
325
402
  # The HTTP status the mock answered with; nil when the request failed without a response.
326
403
  attr_accessor status: Integer?
327
404
  def json: () -> json_value
405
+ # An upload form's `options` (parsed) and `file` (its bytes); nil for any other body.
406
+ def form: () -> { 'options' => Hash[String, json_value], 'file' => String }?
328
407
  end
329
408
 
330
409
  interface _Clock
@@ -366,6 +445,8 @@ module CalmLens
366
445
  def enqueue: (body: json_input, ?status: Integer, ?headers: Hash[String, String]) -> self
367
446
  def enqueue_raw: (body: String, ?status: Integer, ?headers: Hash[String, String]) -> self
368
447
  def enqueue_error: (Exception error) -> self
448
+ # Fetches a mock CDN URL with a GET and without the API key, as a browser would.
449
+ def fetch_cdn: (String url) -> Response
369
450
  def enqueue_event: (mock_event_input event) -> event
370
451
  # Applies a reviewer's decision and adds the matching `asset.<decision>` event.
371
452
  def decide: (String asset_id, asset_moderation_decision decision) -> asset
@@ -416,6 +497,12 @@ module CalmLens
416
497
  QUALIFIERS: Hash[String, Array[String]]
417
498
  # Qualifiers the input type sets, which a TestInputs scenario cannot list.
418
499
  INPUT_QUALIFIERS: Array[String]
500
+ # Test scenario detections: entries per list, the basis points of a full side, the oldest age
501
+ # and the age presets.
502
+ TEST_DETECTIONS_MAX: Integer
503
+ TEST_DETECTION_BOX_MAX: Integer
504
+ TEST_FACE_AGE_MAX: Integer
505
+ TEST_FACE_AGE_PRESETS: Array[String]
419
506
  end
420
507
 
421
508
  # Signed test inputs: text codes, test images and media phrases signed with a project's secret
@@ -426,9 +513,20 @@ module CalmLens
426
513
  type regional_action = { "region" => region, "action" => "limit" | "age-restrict" | "block" }
427
514
  # One qualifying instance; set an action on every instance or on none.
428
515
  type test_instance = { "category" => classification_category, ?"score" => number, ?"action" => action, "qualifiers" => Array[String] }
516
+ # `[left, top, width, height]`: Integers in basis points of the image, 0 to 10000, inside it.
517
+ type detection_box = [Integer, Integer, Integer, Integer]
518
+ # A person a test image reports, with an optional Integer confidence from 0 to 100.
519
+ type test_person = { "box" => detection_box, ?"confidence" => Integer }
520
+ # A face's age: a preset, or a range of Integers from 0 to 120 with `low` at most `high`.
521
+ type test_face_age = "adult" | "minor" | { "low" => Integer, "high" => Integer }
522
+ # A face a test image reports, with an optional confidence and age.
523
+ type test_face = { "box" => detection_box, ?"confidence" => Integer, ?"age" => test_face_age }
524
+ # The people and faces a test image reports: at most 32 of each, or `unavailable` to report the
525
+ # feature as not evaluated.
526
+ type test_detections = { ?"people" => Array[test_person] | "unavailable", ?"faces" => Array[test_face] | "unavailable" }
429
527
  # A scenario with String keys; Symbol and snake_case keys are accepted as a plain Hash.
430
528
  type scenario = { ?"regionalActions" => Array[regional_action], ?"unassessedCategories" => Array[classification_category],
431
- "instances" => Array[test_instance] }
529
+ "instances" => Array[test_instance], ?"detections" => test_detections }
432
530
  type signal = classification_category | "sexual.involving-minors" | "harassment.insult" | "sexual.adult"
433
531
  | "hate.regional-symbol" | "violence.graphic"
434
532
  type media_signal = classification_category | "violence.graphic"
@@ -447,7 +545,8 @@ module CalmLens
447
545
  REGIONS: Array[region]
448
546
  SIGNALS: Array[signal]
449
547
  MEDIA_SIGNALS: Array[media_signal]
450
- # clean, limited, adult_image, adult_text, regional_delivery, germany and mixed (deeply frozen).
548
+ # clean, limited, adult_image, adult_text, regional_delivery, germany, mixed, one_face, no_face,
549
+ # adult_face, minor_face and group (deeply frozen).
451
550
  PRESETS: Hash[Symbol, scenario]
452
551
 
453
552
  def self.for_signal: (signal signal, ?("text" | "image") kind) -> scenario