calmlens 0.19.0 → 0.20.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/lib/calmlens/assets.rb +5 -4
- data/lib/calmlens/internal.rb +10 -0
- data/lib/calmlens/lenses.rb +3 -2
- data/lib/calmlens/option_keys.rb +23 -0
- data/lib/calmlens/policies.rb +3 -3
- data/lib/calmlens/resource.rb +8 -2
- data/lib/calmlens/testing/fake_api.rb +76 -16
- data/lib/calmlens/testing/test_inputs.rb +2 -1
- data/lib/calmlens/testing.rb +4 -1
- data/lib/calmlens/transform_rules.rb +120 -0
- data/lib/calmlens/transform_schema.rb +236 -0
- data/lib/calmlens/transforms.rb +16 -0
- data/lib/calmlens/upload_mime_types.rb +43 -0
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens.rb +3 -0
- data/sig/calmlens.rbs +2 -1
- data/sig/types.rbs +4 -2
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3b2b65d6e8ec874bb4971fd4e4568e6cc424ecce6f060c6ce796bc2eb1a1c6d
|
|
4
|
+
data.tar.gz: 54a14554c90bec933b2ebaf0482918f3902497c6bdff6d8204e6b374fb881aab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 899100986bd5d7534a535a92f1af8c91a06fe8eadd123dfb9ef03a63d22193536c6d3bdaf72936905bee03379e88a3bb53e8197ae259fddec8b32e1d0eb951b8
|
|
7
|
+
data.tar.gz: 33d8fa683ba8d59117f9be22611f0ba5a12b4b2d557a4bbf61cdfd3b782f1d4186a3197bdaf3e26df8e0ff07c8ed53ca79e0bdeb84feae3d1f9ce904976ae7c6
|
data/lib/calmlens/assets.rb
CHANGED
|
@@ -510,10 +510,11 @@ module CalmLens
|
|
|
510
510
|
|
|
511
511
|
def check_mime(type)
|
|
512
512
|
mime = type.to_s.split(';').first.to_s.strip.downcase
|
|
513
|
-
|
|
514
|
-
raise
|
|
515
|
-
|
|
516
|
-
|
|
513
|
+
unless Internal.accepted_upload_mime_type?(mime)
|
|
514
|
+
raise InvalidArgumentError.new(ErrorCodes::INVALID_UPLOAD_MIME_TYPE,
|
|
515
|
+
"#{mime} is not a valid upload file type. Uploads must use a concrete file MIME " \
|
|
516
|
+
'type that CalmLens can store and process, such as image/png, image/jpeg, ' \
|
|
517
|
+
'application/pdf, or text/plain.', details: { 'mimeType' => type })
|
|
517
518
|
end
|
|
518
519
|
raise Internal.invalid('Content type cannot contain line breaks') unless type.match?(Internal::HEADER_VALUE)
|
|
519
520
|
|
data/lib/calmlens/internal.rb
CHANGED
|
@@ -79,6 +79,16 @@ module CalmLens
|
|
|
79
79
|
value.is_a?(Numeric) && value.real? && value.to_f.finite? && value.positive?
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
+
# Whether the API accepts an upload of this MIME type: an image, audio, video, text, document or
|
|
83
|
+
# website type, not a container such as `application/octet-stream`. It refuses any other type
|
|
84
|
+
# with `upload/invalid-mime-type`.
|
|
85
|
+
def accepted_upload_mime_type?(type)
|
|
86
|
+
mime = type.to_s.split(';', 2).first.to_s.strip.downcase
|
|
87
|
+
return false if UPLOAD_MIME_REJECTED.include?(mime)
|
|
88
|
+
|
|
89
|
+
mime.start_with?(*UPLOAD_MIME_PREFIXES) || mime.end_with?(*UPLOAD_MIME_SUFFIXES) || UPLOAD_MIME_TYPES.include?(mime)
|
|
90
|
+
end
|
|
91
|
+
|
|
82
92
|
# Fetches a required option.
|
|
83
93
|
def required(options, key, code = SdkErrorCodes::INVALID_ARGUMENT)
|
|
84
94
|
raise invalid("#{key} is required", code) unless options.key?(key)
|
data/lib/calmlens/lenses.rb
CHANGED
|
@@ -7,12 +7,13 @@ module CalmLens
|
|
|
7
7
|
# The prefix of this resource's operations in Internal::OPTION_KEYS.
|
|
8
8
|
OPERATIONS = nil
|
|
9
9
|
|
|
10
|
+
# Lens definitions are sent as given, nil fields included.
|
|
10
11
|
def ensure_lenses(options)
|
|
11
|
-
call(options, __method__) { |api, request| @session.ensure_lenses(api['lenses'], request) }
|
|
12
|
+
call(options, __method__, keep_nil: true) { |api, request| @session.ensure_lenses(api['lenses'], request) }
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def ensure_lens(options)
|
|
15
|
-
call(options, __method__) { |api, request| @session.ensure_lenses([api], request).first }
|
|
16
|
+
call(options, __method__, keep_nil: true) { |api, request| @session.ensure_lenses([api], request).first }
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
private_constant :Lenses
|
data/lib/calmlens/option_keys.rb
CHANGED
|
@@ -62,5 +62,28 @@ module CalmLens
|
|
|
62
62
|
uploadPresetId visibility
|
|
63
63
|
].freeze
|
|
64
64
|
}.freeze
|
|
65
|
+
|
|
66
|
+
# The option keys each method sends as null when given nil; any other option given as nil
|
|
67
|
+
# is left out, as the API refuses null for it.
|
|
68
|
+
NULLABLE_OPTION_KEYS = {
|
|
69
|
+
'assets.classify' => %w[
|
|
70
|
+
authorId context description externalId folderId metadata processingOptions uploadPresetId visibility
|
|
71
|
+
].freeze,
|
|
72
|
+
'assets.create' => %w[
|
|
73
|
+
authorId context description externalId folderId metadata processingOptions uploadPresetId visibility
|
|
74
|
+
].freeze,
|
|
75
|
+
'assets.each' => %w[filter folderId].freeze,
|
|
76
|
+
'assets.list' => %w[filter folderId].freeze,
|
|
77
|
+
'assets.move' => %w[folderId].freeze,
|
|
78
|
+
'assets.update' => %w[description externalId externalUrl metadata tags visibility].freeze,
|
|
79
|
+
'folders.create' => %w[parentId].freeze,
|
|
80
|
+
'folders.move' => %w[parentId].freeze,
|
|
81
|
+
'hashLists.addEntry' => %w[label quality].freeze,
|
|
82
|
+
'hashLists.create' => %w[description].freeze,
|
|
83
|
+
'hashLists.update' => %w[description].freeze,
|
|
84
|
+
'text.classify' => %w[
|
|
85
|
+
authorId context description externalId folderId metadata processingOptions uploadPresetId visibility
|
|
86
|
+
].freeze
|
|
87
|
+
}.freeze
|
|
65
88
|
end
|
|
66
89
|
end
|
data/lib/calmlens/policies.rb
CHANGED
|
@@ -61,9 +61,9 @@ module CalmLens
|
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
# Switches automatic updates on or off for the
|
|
65
|
-
# the saved copy with the latest preset settings under the same ID; switching off pins
|
|
66
|
-
# current preset version as a copy with unchanged settings.
|
|
64
|
+
# Switches automatic updates on or off for the organisation's own preset policy. Switching on
|
|
65
|
+
# replaces the saved copy with the latest preset settings under the same ID; switching off pins
|
|
66
|
+
# the current preset version as a copy with unchanged settings.
|
|
67
67
|
# @option options [String] :policy_id
|
|
68
68
|
# @option options [Boolean] :enabled
|
|
69
69
|
# @option options [Integer] :revision the policy's current revision
|
data/lib/calmlens/resource.rb
CHANGED
|
@@ -28,9 +28,15 @@ module CalmLens
|
|
|
28
28
|
# Runs a public method: splits options, and raises every argument problem as {InvalidArgumentError}.
|
|
29
29
|
# `method` names the public method: an option it doesn't take, such as a misspelt one, raises
|
|
30
30
|
# `sdk/invalid-argument` instead of being silently dropped.
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
# An option given as nil is left out, as if omitted, unless the API takes null for it
|
|
32
|
+
# (Internal::NULLABLE_OPTION_KEYS), such as `description: nil` to clear a description;
|
|
33
|
+
# `keep_nil: true` sends every option as given, as for a lens definition.
|
|
34
|
+
def call(options, method, keep_nil: false)
|
|
35
|
+
operation = [self.class::OPERATIONS, Internal.camelize(method)].compact.join('.')
|
|
36
|
+
check_option_keys(options, operation)
|
|
33
37
|
api, request = Internal.options(options)
|
|
38
|
+
nullable = Internal::NULLABLE_OPTION_KEYS.fetch(operation, [])
|
|
39
|
+
api = api.reject { |key, value| value.nil? && !nullable.include?(key) } unless keep_nil
|
|
34
40
|
yield api, request
|
|
35
41
|
rescue Error
|
|
36
42
|
raise
|
|
@@ -240,6 +240,47 @@ module CalmLens
|
|
|
240
240
|
# A request the API's schema refuses: `validation/<issue>` with HTTP 400 and the field.
|
|
241
241
|
def invalid!(code, path) = raise(Failure.new(code, "Invalid #{path}", details: { 'path' => path }, status: 400))
|
|
242
242
|
|
|
243
|
+
# Like the API's schema (METADATA_ITEM_SCHEMA): metadata is nil or a list of `{id, value}`
|
|
244
|
+
# Hashes with a String `id` and any JSON `value`.
|
|
245
|
+
def check_metadata(metadata)
|
|
246
|
+
return if metadata.nil?
|
|
247
|
+
|
|
248
|
+
invalid!('validation/invalid-type', 'metadata') unless metadata.is_a?(Array)
|
|
249
|
+
metadata.each_with_index do |item, index|
|
|
250
|
+
invalid!('validation/invalid-type', "metadata.#{index}") unless item.is_a?(Hash)
|
|
251
|
+
invalid!('validation/invalid-type', "metadata.#{index}.id") unless item['id'].is_a?(String)
|
|
252
|
+
invalid!('validation/invalid-value', "metadata.#{index}.value") unless item.key?('value')
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Like the API's schema, checked before the asset is looked up: asset updates take no null
|
|
257
|
+
# name, and signed URLs no null field.
|
|
258
|
+
def check_asset_body(method, action, json)
|
|
259
|
+
case [method, action]
|
|
260
|
+
in ['PATCH', nil]
|
|
261
|
+
check_metadata(json['metadata'])
|
|
262
|
+
invalid!('validation/invalid-type', 'name') if json.key?('name') && json['name'].nil?
|
|
263
|
+
in ['POST', 'signed-url']
|
|
264
|
+
%w[viewerClaims resource lens transform expiresInSeconds].each do |key|
|
|
265
|
+
next unless json.key?(key) && json[key].nil?
|
|
266
|
+
|
|
267
|
+
invalid!(key == 'resource' ? 'validation/invalid-value' : 'validation/invalid-type', key)
|
|
268
|
+
end
|
|
269
|
+
else nil
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Like the API: an upload's metadata is checked, then its MIME type, which must be one the
|
|
274
|
+
# API can moderate (`upload/invalid-mime-type` with `details.mimeType`).
|
|
275
|
+
def check_upload(options)
|
|
276
|
+
check_metadata(options['metadata'])
|
|
277
|
+
type = options['contentType']
|
|
278
|
+
return if type.nil? || Internal.accepted_upload_mime_type?(type)
|
|
279
|
+
|
|
280
|
+
fail!(ErrorCodes::INVALID_UPLOAD_MIME_TYPE, "#{type} is not a valid upload file type",
|
|
281
|
+
details: { 'mimeType' => type })
|
|
282
|
+
end
|
|
283
|
+
|
|
243
284
|
# Like the API's query schema: an integer from 1 to `max`.
|
|
244
285
|
def check_range(value, path, max)
|
|
245
286
|
invalid!('validation/too-small', path) if !value.is_a?(Numeric) || value < 1
|
|
@@ -253,10 +294,11 @@ module CalmLens
|
|
|
253
294
|
parts = uri.path.split('/').reject(&:empty?).map { |part| URI.decode_www_form_component(part) }
|
|
254
295
|
query = URI.decode_www_form(uri.query.to_s).to_h.to_h { |key, value| [key, query_value(key, value)] }
|
|
255
296
|
content_type = header(headers, 'Content-Type')
|
|
256
|
-
|
|
297
|
+
upload = header(headers, 'X-CalmLens-Upload')
|
|
298
|
+
# A raw upload's body is the file itself, whatever its type.
|
|
299
|
+
json = content_type == 'application/json' && body && upload.nil? ? JSON.parse(body) : {}
|
|
257
300
|
# HTTP stacks trim header values, so the API sees the key without surrounding whitespace.
|
|
258
301
|
key = header(headers, 'Idempotency-Key')&.to_s&.gsub(/\A[ \t\r\n]+|[ \t\r\n]+\z/, '')
|
|
259
|
-
upload = header(headers, 'X-CalmLens-Upload')
|
|
260
302
|
return dispatch(method, parts, query, json, body, upload) unless key && method == 'POST'
|
|
261
303
|
|
|
262
304
|
fail!(ErrorCodes::INVALID_IDEMPOTENCY_KEY) unless key.match?(IDEMPOTENCY_KEY)
|
|
@@ -507,6 +549,7 @@ module CalmLens
|
|
|
507
549
|
end
|
|
508
550
|
|
|
509
551
|
def create_asset(options, queue, url: nil, data: nil, open: false)
|
|
552
|
+
check_upload(options)
|
|
510
553
|
check_folder(options['folderId']) if options['folderId']
|
|
511
554
|
if options['externalId'] && project_assets.any? { |item| item['externalId'] == options['externalId'] }
|
|
512
555
|
fail!(ErrorCodes::DUPLICATE_EXTERNAL_ID)
|
|
@@ -567,6 +610,7 @@ module CalmLens
|
|
|
567
610
|
# In the API's order: a declared total, then a single final part as the whole upload, then the
|
|
568
611
|
# part itself. The queue mode and declared total are saved with an open upload.
|
|
569
612
|
def start_multipart(query, data)
|
|
613
|
+
check_upload(query)
|
|
570
614
|
total = query['totalSizeInBytes']
|
|
571
615
|
type = kind(query['contentType'].to_s, nil)
|
|
572
616
|
final = query['isFinal'] == true
|
|
@@ -705,6 +749,7 @@ module CalmLens
|
|
|
705
749
|
|
|
706
750
|
def asset(method, parts, query, json, body)
|
|
707
751
|
id = parts[1]
|
|
752
|
+
check_asset_body(method, parts[2], json)
|
|
708
753
|
item = @state.assets[id]
|
|
709
754
|
fail!(ErrorCodes::ASSET_NOT_FOUND) unless item && item['projectId'] == @project_id
|
|
710
755
|
case [method, parts[2]]
|
|
@@ -887,9 +932,9 @@ module CalmLens
|
|
|
887
932
|
touch(item.merge!('parentId' => parent_id))
|
|
888
933
|
end
|
|
889
934
|
|
|
890
|
-
# Policies: the project starts on
|
|
891
|
-
# mock holds no built-in preset settings: a preset's settings are
|
|
892
|
-
# at version 1, and uploads are never decided by a policy.
|
|
935
|
+
# Policies: the project starts on the organisation's own General preset policy, following
|
|
936
|
+
# automatic updates. The mock holds no built-in preset settings: a preset's settings are
|
|
937
|
+
# `{ 'categorySettings' => {} }` at version 1, and uploads are never decided by a policy.
|
|
893
938
|
def new_policy(name, settings, description: '', automatic_updates: false, preset: nil)
|
|
894
939
|
now = @now.call
|
|
895
940
|
item = { 'id' => @create_id.call('pol'), 'name' => name, 'description' => description, 'settings' => settings,
|
|
@@ -901,7 +946,7 @@ module CalmLens
|
|
|
901
946
|
def ensure_policies
|
|
902
947
|
return if @project_policy_id
|
|
903
948
|
|
|
904
|
-
preset = { 'id' => 'general-community', 'version' => 1, '
|
|
949
|
+
preset = { 'id' => 'general-community', 'version' => 1, 'own' => true }
|
|
905
950
|
@project_policy_id = new_policy(PRESET_NAMES['general-community'], { 'categorySettings' => {} },
|
|
906
951
|
automatic_updates: true, preset: preset)['id']
|
|
907
952
|
end
|
|
@@ -932,7 +977,7 @@ module CalmLens
|
|
|
932
977
|
end
|
|
933
978
|
description = json['description'].is_a?(String) ? json['description'] : ''
|
|
934
979
|
item = if preset_id
|
|
935
|
-
origin = { 'id' => preset_id, 'version' => 1, '
|
|
980
|
+
origin = { 'id' => preset_id, 'version' => 1, 'own' => false }
|
|
936
981
|
new_policy(named ? name.strip : PRESET_NAMES[preset_id], { 'categorySettings' => {} },
|
|
937
982
|
description: description, preset: origin)
|
|
938
983
|
else
|
|
@@ -992,9 +1037,7 @@ module CalmLens
|
|
|
992
1037
|
def set_policy_automatic_updates(item, json)
|
|
993
1038
|
enabled = json['enabled'] == true
|
|
994
1039
|
preset = item['preset']
|
|
995
|
-
if preset.nil? || preset['
|
|
996
|
-
fail!(ErrorCodes::POLICY_AUTOMATIC_UPDATES_UNAVAILABLE)
|
|
997
|
-
end
|
|
1040
|
+
fail!(ErrorCodes::POLICY_AUTOMATIC_UPDATES_UNAVAILABLE) if preset.nil? || !preset['own'] || item['automaticUpdates'] == enabled
|
|
998
1041
|
fail!(ErrorCodes::POLICY_REVISION_CONFLICT) unless json['revision'] == item['revision']
|
|
999
1042
|
fail!(ErrorCodes::POLICY_PRESET_VERSION_CONFLICT) unless json['presetVersion'] == 1
|
|
1000
1043
|
|
|
@@ -1168,13 +1211,27 @@ module CalmLens
|
|
|
1168
1211
|
# private asset needs an API key with `assets:download` for its folder.
|
|
1169
1212
|
def serve(method, relative)
|
|
1170
1213
|
path, query = relative.split('#', 2).first.split('?', 2)
|
|
1171
|
-
|
|
1172
|
-
_, project, identifier, resource =
|
|
1214
|
+
raw_segments = path.split('/').reject(&:empty?)
|
|
1215
|
+
_, project, identifier, resource = raw_segments.map { |part| URI.decode_www_form_component(part) }
|
|
1216
|
+
# Like the CDN, the selector after the resource is read first, and lenses and transforms
|
|
1217
|
+
# apply only to the rendered resource.
|
|
1218
|
+
begin
|
|
1219
|
+
lens, options = TransformRules.parse_selector(raw_segments.drop(4).join('/'))
|
|
1220
|
+
rescue ArgumentError
|
|
1221
|
+
return invalid_transform
|
|
1222
|
+
end
|
|
1223
|
+
return invalid_transform if resource == 'original' && (lens || options)
|
|
1224
|
+
|
|
1173
1225
|
item = project == @project_id && project_assets.find { |asset| [asset['id'], asset['externalId']].include?(identifier) }
|
|
1174
1226
|
return error('asset/not-found', 'Asset not found', status: 404) unless item && RESOURCES.include?(resource)
|
|
1175
|
-
|
|
1176
1227
|
return placeholder(method, item) if resource == 'placeholder'
|
|
1177
1228
|
|
|
1229
|
+
deliver(method, item, URI.decode_www_form(query.to_s).to_h, options)
|
|
1230
|
+
end
|
|
1231
|
+
|
|
1232
|
+
# Serves a found asset's content after the delivery, age and access checks, then the
|
|
1233
|
+
# transform options' schema check.
|
|
1234
|
+
def deliver(method, item, params, options)
|
|
1178
1235
|
moderation = item['classification']['moderation']['status']
|
|
1179
1236
|
stored = @state.content[item['id']]
|
|
1180
1237
|
if stored.nil? || item['upload']['status'] == 'uploading' || %w[blocked purged].include?(moderation) ||
|
|
@@ -1182,9 +1239,7 @@ module CalmLens
|
|
|
1182
1239
|
return not_available
|
|
1183
1240
|
end
|
|
1184
1241
|
|
|
1185
|
-
|
|
1186
|
-
opted_in = viewer_age_confirmed?(params)
|
|
1187
|
-
if moderation == 'age-restricted' && !opted_in
|
|
1242
|
+
if moderation == 'age-restricted' && !viewer_age_confirmed?(params)
|
|
1188
1243
|
return error('viewer/age-confirmation-required', 'Age confirmation required',
|
|
1189
1244
|
status: 403)
|
|
1190
1245
|
end
|
|
@@ -1195,6 +1250,9 @@ module CalmLens
|
|
|
1195
1250
|
'This API key needs assets:download permission for this asset’s folder.', status: 403)
|
|
1196
1251
|
end
|
|
1197
1252
|
|
|
1253
|
+
refused = options ? TransformRules.check(options).flatten : []
|
|
1254
|
+
return error('asset/invalid-transform-options', "Invalid transform options: #{refused.join(', ')}", status: 400) if refused.any?
|
|
1255
|
+
|
|
1198
1256
|
type = stored['type'].to_s.empty? ? 'application/octet-stream' : stored['type']
|
|
1199
1257
|
cdn_response(method, stored['parts'].sort.map(&:last).join.b,
|
|
1200
1258
|
viewer_delivery_headers(item, type, restricted, params))
|
|
@@ -1210,6 +1268,8 @@ module CalmLens
|
|
|
1210
1268
|
headers
|
|
1211
1269
|
end
|
|
1212
1270
|
|
|
1271
|
+
def invalid_transform = error('asset/invalid-transform', 'Invalid transform selector', status: 400)
|
|
1272
|
+
|
|
1213
1273
|
def not_available = error('asset/not-available', 'Asset is not available', status: 403)
|
|
1214
1274
|
|
|
1215
1275
|
# Like the CDN's placeholder handler: an image's ThumbHash, public whatever the asset's
|
|
@@ -82,7 +82,8 @@ module CalmLens
|
|
|
82
82
|
when 'sexual.adult' then PRESETS[kind == 'image' ? :adult_image : :adult_text]
|
|
83
83
|
when 'hate.regional-symbol' then PRESETS[:germany]
|
|
84
84
|
when 'sexual.involving-minors' then { 'instances' => [{ 'category' => 'sexual', 'qualifiers' => ['sexual-minors'] }] }
|
|
85
|
-
when 'violence.graphic'
|
|
85
|
+
when 'violence.graphic'
|
|
86
|
+
{ 'instances' => [{ 'category' => 'violence', 'qualifiers' => %w[violence-injury violence-graphic] }] }
|
|
86
87
|
when *MockClassification::CATEGORIES then { 'instances' => [{ 'category' => signal, 'qualifiers' => [] }] }
|
|
87
88
|
else raise Internal.invalid("Unknown test signal #{signal.inspect}")
|
|
88
89
|
end
|
data/lib/calmlens/testing.rb
CHANGED
|
@@ -128,7 +128,7 @@ module CalmLens
|
|
|
128
128
|
-> { lock.synchronize { last = [(Time.now.to_f * 1000).to_i, last + 1].max } }
|
|
129
129
|
end
|
|
130
130
|
|
|
131
|
-
# @param project_id [String]
|
|
131
|
+
# @param project_id [String] `project-1` by default.
|
|
132
132
|
# @param api_key [String] a key from {.create_api_key}; the default has no grants.
|
|
133
133
|
# @param now [#call] returns the current time in epoch milliseconds. The default clock reads the
|
|
134
134
|
# system time but advances at least 1 ms per reading, so records made in a row have strictly
|
|
@@ -228,6 +228,9 @@ module CalmLens
|
|
|
228
228
|
raise Internal.invalid('retry_after must be a whole number of seconds')
|
|
229
229
|
end
|
|
230
230
|
raise Internal.invalid('details must be a Hash') unless details.nil? || details.is_a?(Hash)
|
|
231
|
+
if !(failure.is_a?(String) && ErrorCodes::STATUSES.key?(failure)) && !(details.nil? && retry_after.nil?)
|
|
232
|
+
raise Internal.invalid('details and retry_after apply only to an API error code')
|
|
233
|
+
end
|
|
231
234
|
|
|
232
235
|
name = operation && operation.to_s.split('.').map { |part| Internal.camelize(part) }.join('.')
|
|
233
236
|
if name && !OPERATION_NAMES.include?(name)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# The CDN's rules for transforms: options checked against the image transform schema
|
|
5
|
+
# (Internal::TRANSFORM_OPTIONS_SCHEMA), and selectors read as the CDN reads them.
|
|
6
|
+
# @api private
|
|
7
|
+
module TransformRules
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Checks transform options against the image transform schema, as the CDN does, and returns
|
|
11
|
+
# the dotted keys it doesn't know and those whose values it refuses (or that are required and
|
|
12
|
+
# missing). nil options are left out of a selector, so they are not checked.
|
|
13
|
+
# @return [Array(Array<String>, Array<String>)] the unknown keys and the invalid keys
|
|
14
|
+
def check(options)
|
|
15
|
+
unknown = []
|
|
16
|
+
invalid = []
|
|
17
|
+
check_rule(Internal::TRANSFORM_OPTIONS_SCHEMA, options, '', unknown, invalid)
|
|
18
|
+
[unknown, invalid]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def check_rule(schema, value, key, unknown, invalid)
|
|
22
|
+
rule = schema.key?('oneOf') ? schema['oneOf'].find { |branch| rule_type?(branch['type'], value) } : schema
|
|
23
|
+
return invalid << key unless rule && rule_allows?(rule, value)
|
|
24
|
+
return unless rule['type'] == 'object'
|
|
25
|
+
|
|
26
|
+
path = ->(name) { key.empty? ? name : "#{key}.#{name}" }
|
|
27
|
+
entries = value.to_h { |name, item| [name.to_s, item] }
|
|
28
|
+
rule.fetch('required', []).each { |name| invalid << path.call(name) if entries[name].nil? }
|
|
29
|
+
entries.keys.sort.each do |name|
|
|
30
|
+
next if entries[name].nil?
|
|
31
|
+
|
|
32
|
+
child = rule.fetch('properties', {})[name]
|
|
33
|
+
child ? check_rule(child, entries[name], path.call(name), unknown, invalid) : unknown << path.call(name)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rule_allows?(rule, value)
|
|
38
|
+
rule_type?(rule['type'], value) &&
|
|
39
|
+
(!rule.key?('enum') || rule['enum'].include?(value)) &&
|
|
40
|
+
(!rule.key?('minimum') || value >= rule['minimum']) &&
|
|
41
|
+
(!rule.key?('maximum') || value <= rule['maximum'])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def rule_type?(type, value)
|
|
45
|
+
case type
|
|
46
|
+
when 'object' then value.is_a?(Hash)
|
|
47
|
+
when 'string' then value.is_a?(String)
|
|
48
|
+
when 'boolean' then [true, false].include?(value)
|
|
49
|
+
when 'number' then value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)
|
|
50
|
+
when 'integer' then value.is_a?(Integer) || (value.is_a?(Float) && value.finite? && value == value.floor)
|
|
51
|
+
else true
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
NUMBER = /\A-?(?:0|[1-9]\d*)(?:\.\d+)?\z/
|
|
56
|
+
LENS = /\A[a-z0-9-]{1,50}\z/
|
|
57
|
+
|
|
58
|
+
# Reads a CDN selector, the path text after the resource as sent (still percent-encoded), as the
|
|
59
|
+
# CDN does: an optional first `@lens`, then `key=value` options with dotted keys for nested ones (a
|
|
60
|
+
# bare key is `true`). Values are numbers, `true`, `false`, hex colours (`#` added) for colour
|
|
61
|
+
# options, or text. Raises ArgumentError on anything the CDN refuses as `asset/invalid-transform`.
|
|
62
|
+
# Used by the mock CDN.
|
|
63
|
+
# @return [Array(String, Hash)] the lens and the options, each nil when absent
|
|
64
|
+
def parse_selector(selector)
|
|
65
|
+
return [nil, nil] if selector.empty?
|
|
66
|
+
raise ArgumentError, "Invalid transform selector #{selector.inspect}" if selector.include?('/')
|
|
67
|
+
|
|
68
|
+
entries = selector.split(',', -1)
|
|
69
|
+
lens = nil
|
|
70
|
+
if entries.first.start_with?('@')
|
|
71
|
+
lens = selector_text(entries.shift.delete_prefix('@'), selector)
|
|
72
|
+
raise ArgumentError, "Invalid transform selector #{selector.inspect}" unless lens.match?(LENS)
|
|
73
|
+
end
|
|
74
|
+
return [lens, nil] if entries.empty?
|
|
75
|
+
|
|
76
|
+
options = {}
|
|
77
|
+
entries.each { |entry| add_selector_entry(options, entry, selector) }
|
|
78
|
+
[lens, options]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def add_selector_entry(options, entry, selector)
|
|
82
|
+
fail = -> { ArgumentError.new("Invalid transform selector #{selector.inspect}") }
|
|
83
|
+
raise fail.call if entry.empty? || entry.start_with?('@')
|
|
84
|
+
|
|
85
|
+
key, separator, text = entry.partition('=')
|
|
86
|
+
parts = key.split('.', -1)
|
|
87
|
+
raise fail.call unless parts.all? { |part| part.match?(Transforms::OPTION_NAME) }
|
|
88
|
+
|
|
89
|
+
value = separator.empty? || selector_value(key, selector_text(text, selector))
|
|
90
|
+
raise fail.call if value == ''
|
|
91
|
+
|
|
92
|
+
target = parts[0..-2].reduce(options) do |current, part|
|
|
93
|
+
raise fail.call unless current[part].nil? || current[part].is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
current[part] ||= {}
|
|
96
|
+
end
|
|
97
|
+
raise fail.call if target.key?(parts.last)
|
|
98
|
+
|
|
99
|
+
target[parts.last] = value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def selector_value(key, text)
|
|
103
|
+
return text if text.empty?
|
|
104
|
+
return "##{text.downcase}" if Transforms::COLOUR_KEYS.include?(key) && Transforms::HEX_COLOUR.match?("##{text}")
|
|
105
|
+
return text.include?('.') ? Float(text) : Integer(text, 10) if text.match?(NUMBER)
|
|
106
|
+
return text == 'true' if %w[true false].include?(text)
|
|
107
|
+
|
|
108
|
+
text
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Percent-decodes selector text, refusing malformed escapes and text that isn't UTF-8.
|
|
112
|
+
def selector_text(text, selector)
|
|
113
|
+
decoded = text.b.gsub(/%(\h\h)/n) { Regexp.last_match(1).hex.chr }.force_encoding(Encoding::UTF_8)
|
|
114
|
+
raise ArgumentError, "Invalid transform selector #{selector.inspect}" if text.match?(/%(?!\h\h)/) || !decoded.valid_encoding?
|
|
115
|
+
|
|
116
|
+
decoded
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
private_constant :TransformRules
|
|
120
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by packages/sdk-contract/generate.ts from IMAGE_TRANSFORM_OPTIONS_SCHEMA; do not edit.
|
|
4
|
+
# The rules each transform option is checked against; see packages/sdk-contract/README.md.
|
|
5
|
+
|
|
6
|
+
require 'json'
|
|
7
|
+
|
|
8
|
+
module CalmLens
|
|
9
|
+
module Internal
|
|
10
|
+
# The image transform options' schema, with only the rules the SDK checks.
|
|
11
|
+
TRANSFORM_OPTIONS_SCHEMA = JSON.parse(<<~JSON).freeze
|
|
12
|
+
{
|
|
13
|
+
"type": "object",
|
|
14
|
+
"properties": {
|
|
15
|
+
"format": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": [
|
|
18
|
+
"jpg",
|
|
19
|
+
"png",
|
|
20
|
+
"webp",
|
|
21
|
+
"avif",
|
|
22
|
+
"gif"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"width": {
|
|
26
|
+
"type": "integer",
|
|
27
|
+
"minimum": 1,
|
|
28
|
+
"maximum": 10000
|
|
29
|
+
},
|
|
30
|
+
"height": {
|
|
31
|
+
"type": "integer",
|
|
32
|
+
"minimum": 1,
|
|
33
|
+
"maximum": 10000
|
|
34
|
+
},
|
|
35
|
+
"dpr": {
|
|
36
|
+
"type": "number",
|
|
37
|
+
"minimum": 1,
|
|
38
|
+
"maximum": 3
|
|
39
|
+
},
|
|
40
|
+
"anim": {
|
|
41
|
+
"type": "boolean"
|
|
42
|
+
},
|
|
43
|
+
"background": {
|
|
44
|
+
"type": "string"
|
|
45
|
+
},
|
|
46
|
+
"blur": {
|
|
47
|
+
"type": "number"
|
|
48
|
+
},
|
|
49
|
+
"redact": {
|
|
50
|
+
"type": "object",
|
|
51
|
+
"properties": {
|
|
52
|
+
"text": {
|
|
53
|
+
"type": "boolean"
|
|
54
|
+
},
|
|
55
|
+
"personalInformation": {
|
|
56
|
+
"type": "boolean"
|
|
57
|
+
},
|
|
58
|
+
"faces": {
|
|
59
|
+
"type": "boolean"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"border": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"color": {
|
|
67
|
+
"type": "string"
|
|
68
|
+
},
|
|
69
|
+
"width": {
|
|
70
|
+
"type": "number"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
"brightness": {
|
|
75
|
+
"type": "number"
|
|
76
|
+
},
|
|
77
|
+
"contrast": {
|
|
78
|
+
"type": "number"
|
|
79
|
+
},
|
|
80
|
+
"quality": {
|
|
81
|
+
"oneOf": [
|
|
82
|
+
{
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"minimum": 1,
|
|
85
|
+
"maximum": 100
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"type": "string",
|
|
89
|
+
"enum": [
|
|
90
|
+
"low",
|
|
91
|
+
"medium",
|
|
92
|
+
"high"
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
"fit": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"enum": [
|
|
100
|
+
"scale-down",
|
|
101
|
+
"contain",
|
|
102
|
+
"pad",
|
|
103
|
+
"squeeze",
|
|
104
|
+
"cover",
|
|
105
|
+
"crop"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
"flip": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"enum": [
|
|
111
|
+
"h",
|
|
112
|
+
"v",
|
|
113
|
+
"hv"
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
"gamma": {
|
|
117
|
+
"type": "number"
|
|
118
|
+
},
|
|
119
|
+
"segment": {
|
|
120
|
+
"type": "string",
|
|
121
|
+
"enum": [
|
|
122
|
+
"foreground"
|
|
123
|
+
]
|
|
124
|
+
},
|
|
125
|
+
"gravity": {
|
|
126
|
+
"oneOf": [
|
|
127
|
+
{
|
|
128
|
+
"type": "string",
|
|
129
|
+
"enum": [
|
|
130
|
+
"face",
|
|
131
|
+
"left",
|
|
132
|
+
"right",
|
|
133
|
+
"top",
|
|
134
|
+
"bottom",
|
|
135
|
+
"center",
|
|
136
|
+
"auto",
|
|
137
|
+
"entropy"
|
|
138
|
+
]
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"type": "object",
|
|
142
|
+
"properties": {
|
|
143
|
+
"mode": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"enum": [
|
|
146
|
+
"remainder",
|
|
147
|
+
"box-center"
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
"x": {
|
|
151
|
+
"type": "number"
|
|
152
|
+
},
|
|
153
|
+
"y": {
|
|
154
|
+
"type": "number"
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
"required": [
|
|
158
|
+
"mode"
|
|
159
|
+
]
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
},
|
|
163
|
+
"rotate": {
|
|
164
|
+
"type": "integer",
|
|
165
|
+
"enum": [
|
|
166
|
+
0,
|
|
167
|
+
90,
|
|
168
|
+
180,
|
|
169
|
+
270
|
|
170
|
+
]
|
|
171
|
+
},
|
|
172
|
+
"saturation": {
|
|
173
|
+
"type": "number"
|
|
174
|
+
},
|
|
175
|
+
"sharpen": {
|
|
176
|
+
"type": "number"
|
|
177
|
+
},
|
|
178
|
+
"trim": {
|
|
179
|
+
"oneOf": [
|
|
180
|
+
{
|
|
181
|
+
"type": "string",
|
|
182
|
+
"enum": [
|
|
183
|
+
"border"
|
|
184
|
+
]
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
"type": "object",
|
|
188
|
+
"properties": {
|
|
189
|
+
"top": {
|
|
190
|
+
"type": "number"
|
|
191
|
+
},
|
|
192
|
+
"bottom": {
|
|
193
|
+
"type": "number"
|
|
194
|
+
},
|
|
195
|
+
"left": {
|
|
196
|
+
"type": "number"
|
|
197
|
+
},
|
|
198
|
+
"right": {
|
|
199
|
+
"type": "number"
|
|
200
|
+
},
|
|
201
|
+
"width": {
|
|
202
|
+
"type": "number"
|
|
203
|
+
},
|
|
204
|
+
"height": {
|
|
205
|
+
"type": "number"
|
|
206
|
+
},
|
|
207
|
+
"border": {
|
|
208
|
+
"oneOf": [
|
|
209
|
+
{
|
|
210
|
+
"type": "boolean"
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
"type": "object",
|
|
214
|
+
"properties": {
|
|
215
|
+
"color": {
|
|
216
|
+
"type": "string"
|
|
217
|
+
},
|
|
218
|
+
"tolerance": {
|
|
219
|
+
"type": "number"
|
|
220
|
+
},
|
|
221
|
+
"keep": {
|
|
222
|
+
"type": "number"
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
JSON
|
|
235
|
+
end
|
|
236
|
+
end
|
data/lib/calmlens/transforms.rb
CHANGED
|
@@ -42,6 +42,7 @@ module CalmLens
|
|
|
42
42
|
def serialize(value)
|
|
43
43
|
raise Internal.invalid('Transform options must be a Hash') unless value.is_a?(Hash)
|
|
44
44
|
|
|
45
|
+
assert_valid(value)
|
|
45
46
|
pairs = []
|
|
46
47
|
sorted_options(value).each { |key, item| append_pairs(pairs, key, item) }
|
|
47
48
|
raise Internal.invalid('A transform needs at least one option') if pairs.empty?
|
|
@@ -49,6 +50,21 @@ module CalmLens
|
|
|
49
50
|
pairs.join(',')
|
|
50
51
|
end
|
|
51
52
|
|
|
53
|
+
# Raises `sdk/invalid-argument` naming each transform option the CDN would refuse, with
|
|
54
|
+
# `details` `{'unknownKeys', 'invalidKeys'}`.
|
|
55
|
+
def assert_valid(options)
|
|
56
|
+
unknown, invalid = TransformRules.check(options)
|
|
57
|
+
return if unknown.empty? && invalid.empty?
|
|
58
|
+
|
|
59
|
+
problems = []
|
|
60
|
+
problems << "unknown option#{'s' if unknown.size > 1} #{unknown.join(', ')}" unless unknown.empty?
|
|
61
|
+
problems << "invalid value#{'s' if invalid.size > 1} for #{invalid.join(', ')}" unless invalid.empty?
|
|
62
|
+
raise InvalidArgumentError.new(SdkErrorCodes::INVALID_ARGUMENT,
|
|
63
|
+
"Transform options are invalid: #{problems.join('; ')}. Pass the options " \
|
|
64
|
+
"themselves, such as { width: 640, format: 'webp' }.",
|
|
65
|
+
details: { 'unknownKeys' => unknown, 'invalidKeys' => invalid })
|
|
66
|
+
end
|
|
67
|
+
|
|
52
68
|
def sorted_options(options, prefix = '')
|
|
53
69
|
options.map do |key, item|
|
|
54
70
|
name = key.to_s
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Generated by packages/sdk-contract/generate.ts from packages/shared/src/UploadMimeTypes.ts; do not edit.
|
|
4
|
+
# After lower-casing and dropping parameters, a type in REJECTED is refused; otherwise one
|
|
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.
|
|
7
|
+
|
|
8
|
+
module CalmLens
|
|
9
|
+
module Internal
|
|
10
|
+
UPLOAD_MIME_REJECTED = [
|
|
11
|
+
'application/octet-stream',
|
|
12
|
+
'multipart/form-data',
|
|
13
|
+
'multipart/mixed',
|
|
14
|
+
'multipart/alternative',
|
|
15
|
+
'multipart/related',
|
|
16
|
+
'multipart/signed',
|
|
17
|
+
'multipart/encrypted'
|
|
18
|
+
].freeze
|
|
19
|
+
UPLOAD_MIME_PREFIXES = [
|
|
20
|
+
'image/',
|
|
21
|
+
'video/',
|
|
22
|
+
'audio/',
|
|
23
|
+
'text/'
|
|
24
|
+
].freeze
|
|
25
|
+
UPLOAD_MIME_SUFFIXES = [
|
|
26
|
+
'+json',
|
|
27
|
+
'+xml'
|
|
28
|
+
].freeze
|
|
29
|
+
UPLOAD_MIME_TYPES = [
|
|
30
|
+
'application/pdf',
|
|
31
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
32
|
+
'application/msword',
|
|
33
|
+
'application/rtf',
|
|
34
|
+
'application/vnd.oasis.opendocument.text',
|
|
35
|
+
'application/javascript',
|
|
36
|
+
'application/json',
|
|
37
|
+
'application/x-javascript',
|
|
38
|
+
'application/x-yaml',
|
|
39
|
+
'application/xml',
|
|
40
|
+
'application/yaml'
|
|
41
|
+
].freeze
|
|
42
|
+
end
|
|
43
|
+
end
|
data/lib/calmlens/version.rb
CHANGED
data/lib/calmlens.rb
CHANGED
|
@@ -9,8 +9,11 @@ require_relative 'calmlens/version'
|
|
|
9
9
|
require_relative 'calmlens/error_codes'
|
|
10
10
|
require_relative 'calmlens/errors'
|
|
11
11
|
require_relative 'calmlens/option_keys'
|
|
12
|
+
require_relative 'calmlens/transform_schema'
|
|
13
|
+
require_relative 'calmlens/upload_mime_types'
|
|
12
14
|
require_relative 'calmlens/internal'
|
|
13
15
|
require_relative 'calmlens/transforms'
|
|
16
|
+
require_relative 'calmlens/transform_rules'
|
|
14
17
|
require_relative 'calmlens/response'
|
|
15
18
|
require_relative 'calmlens/transport'
|
|
16
19
|
require_relative 'calmlens/upload_body'
|
data/sig/calmlens.rbs
CHANGED
|
@@ -357,7 +357,8 @@ module CalmLens
|
|
|
357
357
|
# Replaces the mock API key's grants for later requests; the key stays the same.
|
|
358
358
|
def api_key_grants=: (Array[Hash[Symbol | String, untyped]] grants) -> Array[Hash[Symbol | String, untyped]]
|
|
359
359
|
# Fails the next request (or the next one `operation` sends) with `transport-error`, `timeout`, an
|
|
360
|
-
# API error code (with optional `details` and `Retry-After` seconds
|
|
360
|
+
# API error code (with optional `details` and `Retry-After` seconds, which only an API error takes) or
|
|
361
|
+
# an exception.
|
|
361
362
|
def fail_next_request: (?(String | Exception) failure, ?operation: String?, ?details: Hash[String | Symbol, untyped]?,
|
|
362
363
|
?retry_after: Integer?) -> self
|
|
363
364
|
def state: () -> Testing::State
|
data/sig/types.rbs
CHANGED
|
@@ -639,12 +639,14 @@ module CalmLens
|
|
|
639
639
|
?"linkSafety" => link_safety_settings
|
|
640
640
|
}
|
|
641
641
|
# Where a policy started from a built-in preset. `version` is the release it is pinned to,
|
|
642
|
-
# `latestVersion` the newest; `
|
|
642
|
+
# `latestVersion` the newest; `own` is whether this is the organisation's own policy for the preset
|
|
643
|
+
# rather than a copy made from it. An organisation has at most one per preset, and only it can
|
|
644
|
+
# follow automatic updates.
|
|
643
645
|
type policy_preset_origin = {
|
|
644
646
|
"id" => policy_preset_id,
|
|
645
647
|
"version" => Integer,
|
|
646
648
|
"latestVersion" => Integer,
|
|
647
|
-
"
|
|
649
|
+
"own" => bool
|
|
648
650
|
}
|
|
649
651
|
# A moderation policy. Policies belong to the project's organisation and its projects share them.
|
|
650
652
|
# Send `revision` with each change; a policy changed since is refused with `policy/revision-conflict`.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: calmlens
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.20.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- CalmLens
|
|
@@ -45,9 +45,12 @@ files:
|
|
|
45
45
|
- lib/calmlens/testing/test_input_scenario.rb
|
|
46
46
|
- lib/calmlens/testing/test_inputs.rb
|
|
47
47
|
- lib/calmlens/text.rb
|
|
48
|
+
- lib/calmlens/transform_rules.rb
|
|
49
|
+
- lib/calmlens/transform_schema.rb
|
|
48
50
|
- lib/calmlens/transforms.rb
|
|
49
51
|
- lib/calmlens/transport.rb
|
|
50
52
|
- lib/calmlens/upload_body.rb
|
|
53
|
+
- lib/calmlens/upload_mime_types.rb
|
|
51
54
|
- lib/calmlens/upload_tokens.rb
|
|
52
55
|
- lib/calmlens/version.rb
|
|
53
56
|
- lib/calmlens/webhooks.rb
|