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.
- checksums.yaml +4 -4
- data/README.md +4 -1
- data/lib/calmlens/assets.rb +149 -110
- data/lib/calmlens/cdn.rb +197 -41
- data/lib/calmlens/error_codes.rb +100 -84
- data/lib/calmlens/errors.rb +11 -2
- data/lib/calmlens/events.rb +50 -1
- data/lib/calmlens/folders.rb +14 -9
- data/lib/calmlens/hash_lists.rb +29 -9
- data/lib/calmlens/internal.rb +9 -19
- data/lib/calmlens/lenses.rb +79 -6
- data/lib/calmlens/option_keys.rb +56 -26
- data/lib/calmlens/policies.rb +50 -18
- data/lib/calmlens/project_configuration.rb +113 -0
- data/lib/calmlens/resource.rb +24 -6
- data/lib/calmlens/server_client.rb +17 -6
- data/lib/calmlens/session.rb +29 -12
- data/lib/calmlens/testing/asset_filters.rb +39 -0
- data/lib/calmlens/testing/classification.rb +8 -2
- data/lib/calmlens/testing/fake_api.rb +950 -397
- data/lib/calmlens/testing/mock_decision.rb +6 -4
- data/lib/calmlens/testing/rsql.rb +245 -0
- data/lib/calmlens/testing/test_input_detections.rb +61 -0
- data/lib/calmlens/testing/test_input_scenario.rb +6 -2
- data/lib/calmlens/testing/test_inputs.rb +30 -10
- data/lib/calmlens/testing.rb +56 -15
- data/lib/calmlens/transform_rules.rb +1 -1
- data/lib/calmlens/upload_body.rb +85 -2
- data/lib/calmlens/upload_mime_types.rb +9 -1
- data/lib/calmlens/upload_tokens.rb +3 -3
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens/webhooks.rb +58 -13
- data/lib/calmlens.rb +1 -0
- data/sig/calmlens.rbs +112 -13
- data/sig/error_codes.rbs +28 -20
- data/sig/qualifiers.rbs +2 -0
- data/sig/types.rbs +488 -190
- metadata +6 -2
data/lib/calmlens/upload_body.rb
CHANGED
|
@@ -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
|
-
|
|
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/
|
|
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] :
|
|
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('
|
|
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("
|
|
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']
|
data/lib/calmlens/version.rb
CHANGED
data/lib/calmlens/webhooks.rb
CHANGED
|
@@ -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, :
|
|
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',
|
|
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']
|
|
90
|
-
|
|
91
|
-
|
|
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', []),
|
|
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) && (
|
|
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
|
|
152
|
-
|
|
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
|
|
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) ->
|
|
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
|
|
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
|
|
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
|