calmlens 0.21.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 +2 -1
- data/lib/calmlens/assets.rb +46 -16
- data/lib/calmlens/error_codes.rb +20 -0
- data/lib/calmlens/errors.rb +9 -0
- data/lib/calmlens/option_keys.rb +10 -1
- data/lib/calmlens/project_configuration.rb +113 -0
- data/lib/calmlens/server_client.rb +9 -0
- data/lib/calmlens/testing/fake_api.rb +281 -42
- data/lib/calmlens/testing/mock_decision.rb +2 -1
- data/lib/calmlens/testing/test_inputs.rb +1 -2
- data/lib/calmlens/testing.rb +20 -3
- data/lib/calmlens/upload_body.rb +85 -2
- data/lib/calmlens/version.rb +1 -1
- data/lib/calmlens.rb +1 -0
- data/sig/calmlens.rbs +25 -0
- data/sig/error_codes.rbs +10 -0
- data/sig/types.rbs +166 -7
- metadata +2 -1
|
@@ -43,8 +43,7 @@ module CalmLens
|
|
|
43
43
|
# semantic search.
|
|
44
44
|
# @api private
|
|
45
45
|
class FakeAPI
|
|
46
|
-
JSON_QUERY = %w[wait isFinal sizeInBytes totalSizeInBytes partNumber pageSize
|
|
47
|
-
tags context].freeze
|
|
46
|
+
JSON_QUERY = %w[wait isFinal sizeInBytes totalSizeInBytes partNumber pageSize].freeze
|
|
48
47
|
# Keyword search fields, in the order matches are reported, and where each value sits in the asset.
|
|
49
48
|
SEARCH_FIELDS = { 'name' => %w[name], 'id' => %w[id], 'externalId' => %w[externalId],
|
|
50
49
|
'description' => %w[description], 'tags' => %w[tags], 'type' => %w[type],
|
|
@@ -82,7 +81,7 @@ module CalmLens
|
|
|
82
81
|
# from its pixels. The `placeholder` resource serves it as a PNG, like the CDN.
|
|
83
82
|
THUMBHASH = '1QcSHQRnh493V4dIh4eXh1h4kJUI'
|
|
84
83
|
# Removing or withholding content removes its rendered file, adaptive stream and preview, as the API does.
|
|
85
|
-
NO_RENDERING = { 'rendered' => nil, 'hls' => nil, 'preview' => nil }.freeze
|
|
84
|
+
NO_RENDERING = { 'rendered' => nil, 'hls' => nil, 'hlsStatus' => nil, 'preview' => nil }.freeze
|
|
86
85
|
# The size of the waveform the API makes as an audio asset's preview.
|
|
87
86
|
WAVEFORM_SIZE = { 'width' => 1200, 'height' => 300 }.freeze
|
|
88
87
|
# Audio and video formats browsers play as uploaded, so the API serves them as stored (Asset.ts).
|
|
@@ -112,6 +111,35 @@ module CalmLens
|
|
|
112
111
|
# The mock API key's `{ 'actions', 'folderId', 'includeSubfolders' }` grants.
|
|
113
112
|
attr_accessor :grants
|
|
114
113
|
|
|
114
|
+
# Splits an upload form as the API reads it: exactly an `options` part, then a `file` part, then
|
|
115
|
+
# the closing boundary.
|
|
116
|
+
# @return [Array(String, String), nil] the options part's text and the file's bytes, or nil
|
|
117
|
+
# when the body isn't such a form.
|
|
118
|
+
def self.upload_form(body, content_type)
|
|
119
|
+
boundary = content_type.to_s.match(%r{\Amultipart/form-data\s*;\s*boundary=(?:"([^"]+)"|([^;\s]+))}i)
|
|
120
|
+
return nil unless boundary
|
|
121
|
+
|
|
122
|
+
framed = "\r\n".b + body.to_s.b
|
|
123
|
+
delimiter = "\r\n--#{boundary[1] || boundary[2]}".b
|
|
124
|
+
return nil unless framed.start_with?(delimiter)
|
|
125
|
+
|
|
126
|
+
parts = []
|
|
127
|
+
at = 0
|
|
128
|
+
until framed.byteslice(at + delimiter.bytesize, 2) == '--'
|
|
129
|
+
start = at + delimiter.bytesize
|
|
130
|
+
header_end = framed.index("\r\n\r\n".b, start)
|
|
131
|
+
following = header_end && framed.index(delimiter, header_end + 4)
|
|
132
|
+
return nil unless following
|
|
133
|
+
|
|
134
|
+
name = framed.byteslice(start, header_end - start)[/content-disposition:\s*form-data\s*;[^\r\n]*?\bname="([^"]*)"/i, 1]
|
|
135
|
+
parts << [name, framed.byteslice(header_end + 4, following - header_end - 4)]
|
|
136
|
+
at = following
|
|
137
|
+
end
|
|
138
|
+
return nil unless parts.map(&:first) == %w[options file]
|
|
139
|
+
|
|
140
|
+
[parts[0][1].force_encoding(Encoding::UTF_8), parts[1][1]]
|
|
141
|
+
end
|
|
142
|
+
|
|
115
143
|
def initialize(project_id:, base_url:, cdn_base_url:, now:, create_id:, moderate:, api_key:, grants:)
|
|
116
144
|
@project_id = project_id
|
|
117
145
|
@api_key = api_key
|
|
@@ -127,6 +155,12 @@ module CalmLens
|
|
|
127
155
|
# reset `baseline`) and the one the project moderates with.
|
|
128
156
|
@policies = {}
|
|
129
157
|
@project_policy_id = nil
|
|
158
|
+
# The project's configuration: upload presets by key, the settings it has set, its metadata
|
|
159
|
+
# fields in display order and the revision the settings and fields share.
|
|
160
|
+
@upload_presets = {}
|
|
161
|
+
@project_settings = {}
|
|
162
|
+
@metadata_fields = []
|
|
163
|
+
@settings_revision = 1
|
|
130
164
|
@request_count = 0
|
|
131
165
|
# Each asset's latest `assetSequence`, which the API keeps on the asset row.
|
|
132
166
|
@asset_event_sequences = Hash.new(0)
|
|
@@ -357,19 +391,25 @@ module CalmLens
|
|
|
357
391
|
json = content_type == 'application/json' && body && upload.nil? ? JSON.parse(body) : {}
|
|
358
392
|
# HTTP stacks trim header values, so the API sees the key without surrounding whitespace.
|
|
359
393
|
key = header(headers, 'Idempotency-Key')&.to_s&.gsub(/\A[ \t\r\n]+|[ \t\r\n]+\z/, '')
|
|
360
|
-
|
|
394
|
+
request = { query: query, json: json, body: body, upload: upload, content_type: content_type }
|
|
395
|
+
return dispatch(method, parts, request) unless key && method == 'POST'
|
|
361
396
|
|
|
362
397
|
fail!(ErrorCodes::INVALID_IDEMPOTENCY_KEY) unless key.match?(IDEMPOTENCY_KEY)
|
|
363
398
|
# Like the API: a successful response is saved and replayed for the same key and request;
|
|
364
|
-
# a failed one frees the key.
|
|
365
|
-
|
|
399
|
+
# a failed one frees the key. An upload form counts by its options and file, not its boundary.
|
|
400
|
+
form = upload && FakeAPI.upload_form(body, content_type)
|
|
401
|
+
fingerprint = if form
|
|
402
|
+
[method, relative, 'multipart/form-data', form[0], Digest::SHA256.hexdigest(form[1])]
|
|
403
|
+
else
|
|
404
|
+
[method, relative, content_type, Digest::SHA256.hexdigest(body.to_s)]
|
|
405
|
+
end
|
|
366
406
|
saved = @state.idempotency[key]
|
|
367
407
|
if saved
|
|
368
408
|
fail!(ErrorCodes::IDEMPOTENCY_KEY_REUSED) unless saved[:fingerprint] == fingerprint
|
|
369
409
|
return Response.new(status: saved[:status], body: saved[:body],
|
|
370
410
|
headers: saved[:headers].merge('idempotent-replayed' => 'true', 'x-request-id' => request_id))
|
|
371
411
|
end
|
|
372
|
-
response = dispatch(method, parts,
|
|
412
|
+
response = dispatch(method, parts, request)
|
|
373
413
|
if response.success?
|
|
374
414
|
@state.idempotency[key] = { fingerprint: fingerprint, status: response.status, body: response.body,
|
|
375
415
|
headers: response.headers }
|
|
@@ -387,15 +427,17 @@ module CalmLens
|
|
|
387
427
|
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, "#{key} is not valid JSON")
|
|
388
428
|
end
|
|
389
429
|
|
|
390
|
-
def dispatch(method, parts,
|
|
430
|
+
def dispatch(method, parts, request)
|
|
431
|
+
query, json, body = request.values_at(:query, :json, :body)
|
|
391
432
|
data = case parts.first
|
|
392
|
-
when 'projects' then project(method, parts,
|
|
433
|
+
when 'projects' then project(method, parts, request)
|
|
393
434
|
when 'assets' then asset(method, parts, query, json, body)
|
|
394
435
|
when 'folders' then folder(method, parts, json)
|
|
395
436
|
when 'hash-lists' then hash_list(method, parts, query, json)
|
|
396
437
|
when 'hash-list-entries' then remove_hash_list_entry(method, parts)
|
|
397
438
|
when 'policies' then policies(method, parts, query, json)
|
|
398
439
|
when 'lenses' then lens(method, parts, json)
|
|
440
|
+
when 'upload-presets' then upload_preset(method, parts)
|
|
399
441
|
else route_not_found!(method, parts)
|
|
400
442
|
end
|
|
401
443
|
data.is_a?(Response) ? data : respond(200, deep_copy(data))
|
|
@@ -403,29 +445,40 @@ module CalmLens
|
|
|
403
445
|
|
|
404
446
|
def deep_copy(value) = JSON.parse(JSON.generate(value))
|
|
405
447
|
|
|
406
|
-
|
|
407
|
-
# request: absent is a JSON body, 'content' raw file content and 'part' a raw first part.
|
|
408
|
-
def project(method, parts, query, json, body, upload)
|
|
448
|
+
def project(method, parts, request)
|
|
409
449
|
fail!(ErrorCodes::API_KEY_PROJECT_MISMATCH) unless parts[1] == @project_id
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
in ['
|
|
413
|
-
in ['
|
|
414
|
-
in ['
|
|
415
|
-
in ['
|
|
416
|
-
in ['
|
|
417
|
-
in ['
|
|
418
|
-
in ['
|
|
419
|
-
in ['
|
|
420
|
-
in ['
|
|
421
|
-
in ['
|
|
422
|
-
in ['GET', ['assets'
|
|
423
|
-
in ['GET', ['assets',
|
|
424
|
-
in ['
|
|
425
|
-
in ['POST', ['
|
|
426
|
-
in ['POST', ['
|
|
427
|
-
in ['POST', ['assets']
|
|
428
|
-
|
|
450
|
+
query, json = request.values_at(:query, :json)
|
|
451
|
+
case [method, parts[2..]]
|
|
452
|
+
in ['GET', ['events']] then events(query)
|
|
453
|
+
in ['PUT', ['lenses']] then lenses(json.fetch('lenses', []))
|
|
454
|
+
in ['GET', ['lenses']] then page(@state.lenses.values, query, JSON.generate(['lenses', @project_id]))
|
|
455
|
+
in ['POST', ['lenses']] then create_lens(json)
|
|
456
|
+
in ['GET', ['folders']] then folder_page(query)
|
|
457
|
+
in ['POST', ['folders']] then create_folder(json)
|
|
458
|
+
in ['GET', ['hash-lists']] then page(project_hash_lists, query, JSON.generate([@project_id]))
|
|
459
|
+
in ['POST', ['hash-lists']] then create_hash_list(json)
|
|
460
|
+
in ['GET', ['policy']] then show_policy(find_policy(ensure_policies))
|
|
461
|
+
in ['PUT', ['policy']] then show_policy(assign_policy(json))
|
|
462
|
+
in ['GET', ['assets']] then list(query)
|
|
463
|
+
in ['GET', ['assets', 'search']] then search(query)
|
|
464
|
+
in ['GET', ['assets', String => asset_id, 'timeline']] then timeline(asset_id, query)
|
|
465
|
+
in ['POST', ['assets', 'batch']] then batch(json)
|
|
466
|
+
in ['POST', ['signed-asset-urls']] then sign_asset_url(json)
|
|
467
|
+
in ['POST', ['assets']] then create_upload(request)
|
|
468
|
+
else configuration(method, parts, query, json)
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# A project's upload presets, settings and metadata fields.
|
|
473
|
+
def configuration(method, parts, query, json)
|
|
474
|
+
case [method, parts[2..]]
|
|
475
|
+
in ['GET', ['upload-presets']] then upload_preset_page(query)
|
|
476
|
+
in ['PUT', ['upload-presets']] then ensure_upload_presets(json)
|
|
477
|
+
in ['GET', ['settings']] then show_project_settings
|
|
478
|
+
in ['PUT', ['settings']] then update_project_settings(json)
|
|
479
|
+
in ['GET', ['metadata-fields']] then show_metadata_fields
|
|
480
|
+
in ['PUT', ['metadata-fields']] then ensure_metadata_fields(json)
|
|
481
|
+
in ['DELETE', ['metadata-fields', String => field_id]] then delete_metadata_field(field_id, query)
|
|
429
482
|
else route_not_found!(method, parts)
|
|
430
483
|
end
|
|
431
484
|
end
|
|
@@ -606,6 +659,129 @@ module CalmLens
|
|
|
606
659
|
end
|
|
607
660
|
end
|
|
608
661
|
|
|
662
|
+
# A page of the project's upload presets, oldest first unless `sort` says otherwise.
|
|
663
|
+
def upload_preset_page(query)
|
|
664
|
+
page(@upload_presets.values, query, JSON.generate(['upload-presets', @project_id]),
|
|
665
|
+
default: { 'field' => 'createdAt', 'direction' => 'asc' })
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Like the API: every preset's revision and settings are checked before any is saved, and the
|
|
669
|
+
# stored presets come back in the order sent.
|
|
670
|
+
def ensure_upload_presets(json)
|
|
671
|
+
definitions = json['uploadPresets']
|
|
672
|
+
invalid!('validation/too-small', 'uploadPresets') unless definitions.is_a?(Array) && !definitions.empty?
|
|
673
|
+
|
|
674
|
+
plans = definitions.each_with_index.map do |definition, index|
|
|
675
|
+
%w[key name].each do |field|
|
|
676
|
+
value = definition[field]
|
|
677
|
+
invalid!('validation/invalid-type', "uploadPresets.#{index}.#{field}") unless value.is_a?(String) && !value.empty?
|
|
678
|
+
end
|
|
679
|
+
existing = @upload_presets[definition['key']]
|
|
680
|
+
stored = existing ? existing['revision'] : 0
|
|
681
|
+
if definition.key?('revision') && definition['revision'] != stored
|
|
682
|
+
fail!(ErrorCodes::UPLOAD_PRESET_REVISION_CONFLICT, details: { 'key' => definition['key'], 'revision' => stored })
|
|
683
|
+
end
|
|
684
|
+
values = upload_preset_values(definition)
|
|
685
|
+
limited = ![nil, 0].include?(values['dailyUploadLimit'])
|
|
686
|
+
fail!(ErrorCodes::UPLOAD_PRESET_DAILY_LIMIT_REQUIRED) if values['access'] == 'anyone' && !limited
|
|
687
|
+
|
|
688
|
+
[existing, values]
|
|
689
|
+
end
|
|
690
|
+
{ 'uploadPresets' => plans.map { |existing, values| save_upload_preset(existing, values) } }
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# The stored values a definition sets: fields left out take their defaults.
|
|
694
|
+
def upload_preset_values(definition)
|
|
695
|
+
given = ->(field, default = nil) { definition[field].nil? ? default : definition[field] }
|
|
696
|
+
list = ->(field) { definition[field].is_a?(Array) && !definition[field].empty? ? definition[field].uniq : nil }
|
|
697
|
+
{ 'key' => definition['key'], 'name' => definition['name'], 'access' => given.call('access', 'token'),
|
|
698
|
+
'enabled' => given.call('enabled', true), 'allowedContentTypes' => list.call('allowedContentTypes'),
|
|
699
|
+
'maxSizeInBytes' => given.call('maxSizeInBytes'), 'folderId' => given.call('folderId'),
|
|
700
|
+
'policyId' => given.call('policyId'), 'visibility' => given.call('visibility', 'private'),
|
|
701
|
+
'processingOptions' => given.call('processingOptions'), 'revealDecision' => given.call('revealDecision', false),
|
|
702
|
+
'dailyUploadLimit' => given.call('dailyUploadLimit'), 'allowedOrigins' => list.call('allowedOrigins') }
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
# Saves a preset's values under its key; an unchanged preset keeps its revision.
|
|
706
|
+
def save_upload_preset(existing, values)
|
|
707
|
+
return existing if existing && existing.slice(*values.keys) == values
|
|
708
|
+
|
|
709
|
+
now = @now.call
|
|
710
|
+
@upload_presets[values['key']] =
|
|
711
|
+
{ 'id' => existing ? existing['id'] : @create_id.call('preset'), 'projectId' => @project_id, 'createdByUserId' => nil,
|
|
712
|
+
**values, 'revision' => (existing ? existing['revision'] : 0) + 1,
|
|
713
|
+
'createdAt' => existing ? existing['createdAt'] : now, 'updatedAt' => now }
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
# DELETE /upload-presets/:id.
|
|
717
|
+
def upload_preset(method, parts)
|
|
718
|
+
route_not_found!(method, parts) unless method == 'DELETE' && parts.size == 2
|
|
719
|
+
preset = @upload_presets.values.find { |item| item['id'] == parts[1] }
|
|
720
|
+
fail!(ErrorCodes::UPLOAD_PRESET_NOT_FOUND) unless preset
|
|
721
|
+
@upload_presets.delete(preset['key'])
|
|
722
|
+
{}
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
def show_project_settings
|
|
726
|
+
settings = { 'optOutJurisdictions' => [], 'blockUnmoderatedAssets' => true, 'storeSensitiveMetadata' => false,
|
|
727
|
+
'streamingEnabled' => false, 'aiMediaSearchEnabled' => false, 'remoteUrlRule' => nil,
|
|
728
|
+
'websiteSubmissionsPerMinute' => nil }.merge(@project_settings)
|
|
729
|
+
settings['humanReview'] = { 'allowedSamplePercent' => 0, 'restrictedSamplePercent' => 0, 'uncertaintyMarginPercent' => 0,
|
|
730
|
+
'reviewMissingEvidence' => false }.merge(@project_settings['humanReview'] || {})
|
|
731
|
+
{ 'projectId' => @project_id, 'revision' => @settings_revision, 'settings' => settings }
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# A revision to check, from a body or the query; none refuses nothing.
|
|
735
|
+
def check_settings_revision(revision)
|
|
736
|
+
return if revision.nil? || revision.to_s == @settings_revision.to_s
|
|
737
|
+
|
|
738
|
+
fail!(ErrorCodes::PROJECT_SETTINGS_REVISION_CONFLICT)
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# Replaces the settings: those left out return to their defaults. A real change bumps the revision.
|
|
742
|
+
def update_project_settings(json)
|
|
743
|
+
check_settings_revision(json['revision'])
|
|
744
|
+
invalid!('validation/invalid-type', 'settings') unless json['settings'].is_a?(Hash)
|
|
745
|
+
|
|
746
|
+
before = show_project_settings['settings']
|
|
747
|
+
previous = @project_settings
|
|
748
|
+
@project_settings = json['settings']
|
|
749
|
+
if show_project_settings['settings'] == before
|
|
750
|
+
@project_settings = previous
|
|
751
|
+
else
|
|
752
|
+
@settings_revision += 1
|
|
753
|
+
end
|
|
754
|
+
show_project_settings
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def show_metadata_fields = { 'projectId' => @project_id, 'revision' => @settings_revision, 'fields' => @metadata_fields }
|
|
758
|
+
|
|
759
|
+
# Creates or replaces fields by ID, keeping the others in order. A real change bumps the revision.
|
|
760
|
+
def ensure_metadata_fields(json)
|
|
761
|
+
check_settings_revision(json['revision'])
|
|
762
|
+
fields = json['fields']
|
|
763
|
+
invalid!('validation/too-small', 'fields') unless fields.is_a?(Array) && !fields.empty?
|
|
764
|
+
|
|
765
|
+
by_id = fields.to_h { |field| [field['id'], field] }
|
|
766
|
+
known = @metadata_fields.map { |field| field['id'] }
|
|
767
|
+
merged = @metadata_fields.map { |field| by_id.fetch(field['id'], field) } + fields.reject { |field| known.include?(field['id']) }
|
|
768
|
+
unless merged == @metadata_fields
|
|
769
|
+
@metadata_fields = merged
|
|
770
|
+
@settings_revision += 1
|
|
771
|
+
end
|
|
772
|
+
show_metadata_fields
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def delete_metadata_field(field_id, query)
|
|
776
|
+
kept = @metadata_fields.reject { |field| field['id'] == field_id }
|
|
777
|
+
fail!(ErrorCodes::METADATA_FIELD_NOT_FOUND, details: { 'fieldId' => field_id }) if kept.size == @metadata_fields.size
|
|
778
|
+
|
|
779
|
+
check_settings_revision(query['revision'])
|
|
780
|
+
@metadata_fields = kept
|
|
781
|
+
@settings_revision += 1
|
|
782
|
+
show_metadata_fields
|
|
783
|
+
end
|
|
784
|
+
|
|
609
785
|
# Keyword search like the API: every whitespace-separated token (case-insensitive, at most 8
|
|
610
786
|
# distinct) must appear in some field. Results rank by the API's score, then newest first.
|
|
611
787
|
def search(query)
|
|
@@ -663,7 +839,7 @@ module CalmLens
|
|
|
663
839
|
keep = processing.is_a?(Hash) && processing['keepContentAfterProcessing'] == true
|
|
664
840
|
context = options['context'].is_a?(Hash) ? ModerationContext.select_items(options['context'].fetch('items', [])) : []
|
|
665
841
|
# Fields the API returns as null when they are not set.
|
|
666
|
-
asset = { 'id' => @create_id.call('asset'), 'projectId' => @project_id, 'parentId' => nil,
|
|
842
|
+
asset = { 'id' => @create_id.call('asset'), 'projectId' => @project_id, 'parentId' => nil, 'parentPages' => [],
|
|
667
843
|
'folderId' => options['folderId'], 'uploadPresetId' => options['uploadPresetId'],
|
|
668
844
|
'createdAt' => now, 'updatedAt' => now, 'name' => options['name'], 'description' => options['description'],
|
|
669
845
|
'externalId' => options['externalId'], 'externalUrl' => url || options['externalUrl'],
|
|
@@ -698,7 +874,8 @@ module CalmLens
|
|
|
698
874
|
# Like the API, moderation's results are null until it decides, and a
|
|
699
875
|
# feature's result is null until it has run.
|
|
700
876
|
'moderation' => { 'status' => 'pending', 'manualReviews' => [], 'automatedStatus' => nil,
|
|
701
|
-
'
|
|
877
|
+
'policy' => nil, 'pendingReview' => nil, 'source' => nil, 'rule' => nil,
|
|
878
|
+
'explanationComplete' => nil,
|
|
702
879
|
'categories' => nil, 'flaggedCategories' => [], 'regions' => [],
|
|
703
880
|
'raw' => nil, 'timeline' => nil },
|
|
704
881
|
'ocr' => nil, 'linkDetection' => nil }
|
|
@@ -729,13 +906,75 @@ module CalmLens
|
|
|
729
906
|
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'Multipart upload parts must be at least 5MiB except the final part.')
|
|
730
907
|
end
|
|
731
908
|
|
|
909
|
+
# `POST .../assets` as the API reads it. Asset fields in the query are refused; then the
|
|
910
|
+
# `X-CalmLens-Upload` header picks the body: absent is JSON (text, a first text part or a URL),
|
|
911
|
+
# `content` an upload form with a whole file and `part` one with a first part.
|
|
912
|
+
def create_upload(request)
|
|
913
|
+
query, json, body, upload = request.values_at(:query, :json, :body, :upload)
|
|
914
|
+
in_query = Assets::UPLOAD_OPTION_KEYS.select { |key| query.key?(key) }
|
|
915
|
+
unless in_query.empty?
|
|
916
|
+
fail!(ErrorCodes::UPLOAD_OPTIONS_IN_QUERY, "Send the asset's fields in the request body, not in the query string",
|
|
917
|
+
details: { 'fields' => in_query })
|
|
918
|
+
end
|
|
919
|
+
queue = query['wait'] == false
|
|
920
|
+
return create_from_json(json, queue) if upload.nil?
|
|
921
|
+
|
|
922
|
+
route_not_found!('POST', ['assets']) unless %w[content part].include?(upload)
|
|
923
|
+
|
|
924
|
+
options, bytes = form_fields(body, request[:content_type])
|
|
925
|
+
fields = options.merge('contentType' => query['contentType'])
|
|
926
|
+
return create_asset(fields, queue, data: bytes) if upload == 'content'
|
|
927
|
+
|
|
928
|
+
start_multipart(fields.merge(query.slice('isFinal', 'totalSizeInBytes')), bytes, queue)
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# A JSON create: text as `content` or a first `part` (`{ plainText, type }`), or a URL.
|
|
932
|
+
def create_from_json(json, queue)
|
|
933
|
+
key = %w[content part].find { |name| json.key?(name) }
|
|
934
|
+
return create_asset(json, queue, url: json['url']) unless key
|
|
935
|
+
|
|
936
|
+
text = json[key]
|
|
937
|
+
invalid!('validation/invalid-type', key) unless text.is_a?(Hash) && text['plainText'].is_a?(String) && text['type'].is_a?(String)
|
|
938
|
+
fields = json.except('content', 'part', 'sizeInBytes', 'totalSizeInBytes').merge('contentType' => text['type'])
|
|
939
|
+
data = text['plainText'].encode(Encoding::UTF_8).b
|
|
940
|
+
return create_asset(fields, queue, data: data) if key == 'content'
|
|
941
|
+
|
|
942
|
+
start_multipart(fields.merge('isFinal' => text['isFinal'] == true, 'totalSizeInBytes' => json['totalSizeInBytes']), data, queue)
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# The asset fields in an upload form's options part, and the file's bytes. Like the API, a raw
|
|
946
|
+
# upload must be `multipart/form-data` (`request/unsupported-content-type`, 415), holding an
|
|
947
|
+
# `options` JSON object of known fields with a name, then a `file` part.
|
|
948
|
+
def form_fields(body, content_type)
|
|
949
|
+
mime = content_type.to_s.split(';').first.to_s.strip.downcase
|
|
950
|
+
unless mime == 'multipart/form-data'
|
|
951
|
+
fail!(ErrorCodes::UNSUPPORTED_CONTENT_TYPE, 'Send a raw upload as multipart/form-data: an options part, then a file part.',
|
|
952
|
+
details: { 'contentType' => mime.empty? ? nil : mime })
|
|
953
|
+
end
|
|
954
|
+
form = FakeAPI.upload_form(body, content_type)
|
|
955
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, 'The upload form must hold an options part, then a file part') unless form
|
|
956
|
+
|
|
957
|
+
options = begin
|
|
958
|
+
JSON.parse(form[0])
|
|
959
|
+
rescue JSON::ParserError
|
|
960
|
+
nil
|
|
961
|
+
end
|
|
962
|
+
unless options.is_a?(Hash)
|
|
963
|
+
fail!(ErrorCodes::INVALID_UPLOAD_PAYLOAD, "The options part must be a JSON object with the asset's fields")
|
|
964
|
+
end
|
|
965
|
+
unknown = options.keys.find { |name| !Assets::UPLOAD_OPTION_KEYS.include?(name) }
|
|
966
|
+
invalid!('validation/unknown-field', unknown) if unknown
|
|
967
|
+
invalid!('validation/invalid-type', 'name') unless options['name'].is_a?(String)
|
|
968
|
+
[options, form[1]]
|
|
969
|
+
end
|
|
970
|
+
|
|
732
971
|
# In the API's order: a declared total, then a single final part as the whole upload, then the
|
|
733
972
|
# part itself. Whether to wait and the declared total are saved with an open upload.
|
|
734
|
-
def start_multipart(
|
|
735
|
-
check_upload(
|
|
736
|
-
total =
|
|
737
|
-
type = kind(
|
|
738
|
-
final =
|
|
973
|
+
def start_multipart(fields, data, queue)
|
|
974
|
+
check_upload(fields)
|
|
975
|
+
total = fields['totalSizeInBytes']
|
|
976
|
+
type = kind(fields['contentType'].to_s, nil)
|
|
977
|
+
final = fields['isFinal'] == true
|
|
739
978
|
size = data.to_s.bytesize
|
|
740
979
|
code = (total && image_size_error(type, total, true)) ||
|
|
741
980
|
(final ? total_size_error(total, size, true) || image_size_error(type, size, true) : nil)
|
|
@@ -743,8 +982,8 @@ module CalmLens
|
|
|
743
982
|
check_part_size(size, final)
|
|
744
983
|
fail!(total_size_error(total, size, false)) if !final && total_size_error(total, size, false)
|
|
745
984
|
|
|
746
|
-
asset = create_asset(
|
|
747
|
-
@state.uploads[asset['id']] = { 'queue' =>
|
|
985
|
+
asset = create_asset(fields, queue, data: data, open: !final)
|
|
986
|
+
@state.uploads[asset['id']] = { 'queue' => queue, 'total' => total } unless final
|
|
748
987
|
asset
|
|
749
988
|
end
|
|
750
989
|
|
|
@@ -897,7 +1136,7 @@ module CalmLens
|
|
|
897
1136
|
# play the upload), audio with a waveform preview and video with no poster; and every preview has
|
|
898
1137
|
# its ThumbHash. Text, spreadsheets and other files have neither. The mock decodes nothing, so
|
|
899
1138
|
# sizes are unknown, audio has no peaks, and audio and video never get an adaptive stream.
|
|
900
|
-
def rendering(asset, processed) = { **rendered_file(asset, processed), 'hls' => nil }
|
|
1139
|
+
def rendering(asset, processed) = { **rendered_file(asset, processed), 'hls' => nil, 'hlsStatus' => nil }
|
|
901
1140
|
|
|
902
1141
|
# The rendered file and preview of {#rendering}.
|
|
903
1142
|
def rendered_file(asset, processed)
|
|
@@ -1559,7 +1798,7 @@ module CalmLens
|
|
|
1559
1798
|
# A private asset's info needs a grant or an API key with `assets:download`.
|
|
1560
1799
|
def info(method, item, authorised, params)
|
|
1561
1800
|
restricted = item['visibility'] != 'public'
|
|
1562
|
-
return
|
|
1801
|
+
return asset_not_found if restricted && !authorised
|
|
1563
1802
|
|
|
1564
1803
|
claims = JSON.parse(params.fetch('viewerClaims', '{}'))
|
|
1565
1804
|
body = { 'id' => item['id'], 'projectId' => item['projectId'], 'type' => item['type'], 'externalUrl' => item['externalUrl'],
|
|
@@ -91,7 +91,8 @@ module CalmLens
|
|
|
91
91
|
{ 'raw' => { 'categories' => MockClassification::CATEGORIES.to_h { |category| [category, category_report(category)] },
|
|
92
92
|
'hashMatches' => [] },
|
|
93
93
|
'categories' => evaluated.to_h { |category, value| [category, { 'score' => value, 'action' => action(value) }] },
|
|
94
|
-
|
|
94
|
+
# The mock keeps no policies, so no policy is named.
|
|
95
|
+
'explanationComplete' => true, 'policy' => nil, 'source' => 'policy', 'rule' => nil, 'automatedStatus' => @status,
|
|
95
96
|
'regions' => [], 'linkSafety' => nil, 'flaggedCategories' => evaluated.reject { |_, value| action(value) == 'allow' }.map(&:first) }
|
|
96
97
|
end
|
|
97
98
|
|
|
@@ -8,8 +8,7 @@ require_relative 'test_image_grid'
|
|
|
8
8
|
module CalmLens
|
|
9
9
|
# Signed test inputs: text codes, test images and media phrases that a project's moderation
|
|
10
10
|
# recognises as a declared scenario when they are signed with its secret test input key (shown in
|
|
11
|
-
# the portal's project settings to those who can read them)
|
|
12
|
-
# inputs" turned on, so CalmLens returns that scenario instead of classifying them. The formats
|
|
11
|
+
# the portal's project settings to those who can read them), so CalmLens returns that scenario instead of classifying them. The formats
|
|
13
12
|
# match the JavaScript SDK's `calmlens/test-inputs` byte for byte.
|
|
14
13
|
#
|
|
15
14
|
# A scenario is a Hash with the API's keys, as Strings or Symbols (snake_case keys are converted
|
data/lib/calmlens/testing.rb
CHANGED
|
@@ -47,12 +47,26 @@ module CalmLens
|
|
|
47
47
|
#
|
|
48
48
|
# `path` is relative to `base_url` for API calls (`/projects/<id>/assets`, without `/v1`) and to
|
|
49
49
|
# `cdn_base_url` for CDN requests, with each segment decoded. `query` holds the decoded query
|
|
50
|
-
# string
|
|
51
|
-
#
|
|
52
|
-
#
|
|
50
|
+
# string; an upload's holds only transport values (`wait`, `contentType`, sizes and part
|
|
51
|
+
# numbers). `body` is the bytes sent (streams are read), or nil: JSON text, including a text
|
|
52
|
+
# upload (`{name, ..., content: {plainText, type}}`), an upload form, or a later part's bytes.
|
|
53
|
+
# `status` is the HTTP status the mock answered with, or nil when the request failed without a
|
|
54
|
+
# response.
|
|
53
55
|
Request = Struct.new(:method, :url, :path, :query, :headers, :body, :status, keyword_init: true) do
|
|
54
56
|
# @return [Object] the body parsed as JSON.
|
|
55
57
|
def json = body && JSON.parse(body)
|
|
58
|
+
|
|
59
|
+
# An upload form's asset fields and file: `{'options' => Hash, 'file' => String}`, the file as
|
|
60
|
+
# UTF-8 text for a `text/*` content type and binary otherwise; nil for any other body.
|
|
61
|
+
# @return [Hash, nil]
|
|
62
|
+
def form
|
|
63
|
+
type = headers.find { |name, _| name.to_s.casecmp?('Content-Type') }&.last
|
|
64
|
+
options, file = Testing::FakeAPI.upload_form(body, type)
|
|
65
|
+
return nil unless options
|
|
66
|
+
|
|
67
|
+
text = query['contentType'].to_s.downcase.start_with?('text/')
|
|
68
|
+
{ 'options' => JSON.parse(options), 'file' => text ? file.dup.force_encoding(Encoding::UTF_8) : file }
|
|
69
|
+
end
|
|
56
70
|
end
|
|
57
71
|
|
|
58
72
|
# @return [Testing::State] the stored assets, folders, content and events.
|
|
@@ -70,6 +84,9 @@ module CalmLens
|
|
|
70
84
|
policies: %i[list retrieve retrieve_assigned create update reset upgrade set_automatic_updates delete
|
|
71
85
|
assign],
|
|
72
86
|
lenses: %i[list create retrieve update delete],
|
|
87
|
+
upload_presets: %i[list ensure delete],
|
|
88
|
+
project_settings: %i[get update],
|
|
89
|
+
metadata_fields: %i[list ensure delete],
|
|
73
90
|
text: %i[classify], events: %i[list],
|
|
74
91
|
cdn: %i[serve sign_asset_url get_asset_info] }.freeze
|
|
75
92
|
private_constant :OPERATIONS
|
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
|
data/lib/calmlens/version.rb
CHANGED
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'
|