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.
@@ -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 processingOptions metadata
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
- return dispatch(method, parts, query, json, body, upload) unless key && method == 'POST'
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
- fingerprint = [method, relative, content_type, Digest::SHA256.hexdigest(body.to_s)]
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, query, json, body, upload)
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, query, json, body, upload)
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, query, json, body, upload)
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
- # `upload` is the X-CalmLens-Upload header, which picks how POST .../assets reads the
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
- case [method, parts[2..], upload]
411
- in ['GET', ['events'], _] then events(query)
412
- in ['PUT', ['lenses'], _] then lenses(json.fetch('lenses', []))
413
- in ['GET', ['lenses'], _] then page(@state.lenses.values, query, JSON.generate(['lenses', @project_id]))
414
- in ['POST', ['lenses'], _] then create_lens(json)
415
- in ['GET', ['folders'], _] then folder_page(query)
416
- in ['POST', ['folders'], _] then create_folder(json)
417
- in ['GET', ['hash-lists'], _] then page(project_hash_lists, query, JSON.generate([@project_id]))
418
- in ['POST', ['hash-lists'], _] then create_hash_list(json)
419
- in ['GET', ['policy'], _] then show_policy(find_policy(ensure_policies))
420
- in ['PUT', ['policy'], _] then show_policy(assign_policy(json))
421
- in ['GET', ['assets'], _] then list(query)
422
- in ['GET', ['assets', 'search'], _] then search(query)
423
- in ['GET', ['assets', String => asset_id, 'timeline'], _] then timeline(asset_id, query)
424
- in ['POST', ['assets', 'batch'], _] then batch(json)
425
- in ['POST', ['signed-asset-urls'], _] then sign_asset_url(json)
426
- in ['POST', ['assets'], nil] then create_asset(json, query['wait'] == false, url: json['url'])
427
- in ['POST', ['assets'], 'content'] then create_asset(query, query['wait'] == false, data: body)
428
- in ['POST', ['assets'], 'part'] then start_multipart(query, body)
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
- 'source' => nil, 'rule' => nil, 'explanationComplete' => nil,
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(query, data)
735
- check_upload(query)
736
- total = query['totalSizeInBytes']
737
- type = kind(query['contentType'].to_s, nil)
738
- final = query['isFinal'] == true
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(query, query['wait'] == false, data: data, open: !final)
747
- @state.uploads[asset['id']] = { 'queue' => query['wait'] == false, 'total' => total } unless final
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 error('asset/not-available', 'Asset is not available', status: 404) if restricted && !authorised
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
- 'explanationComplete' => true, 'source' => 'policy', 'rule' => nil, 'automatedStatus' => @status,
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) and the project has "Recognise test
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
@@ -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. `body` is the bytes sent: JSON text, or the uploaded content (streams are read), or
51
- # nil. `status` is the HTTP status the mock answered with, or nil when the request failed
52
- # without a response.
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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module CalmLens
4
6
  # An upload payload: a binary String, or an IO streamed with an exact byte count (or, when its
5
7
  # length is unknown, until it ends, sent with chunked transfer encoding).
@@ -98,6 +100,20 @@ module CalmLens
98
100
  UploadBody.new(@source.slice(offset, length))
99
101
  end
100
102
 
103
+ # This body as the `file` part of an upload form, after an `options` part holding
104
+ # `options_json`: `multipart/form-data` with a random boundary. A stream stays a stream, read
105
+ # as the form is sent, and the form can be sent again whenever this body can.
106
+ # @return [Array(UploadBody, String)] the form and its Content-Type.
107
+ def to_form(options_json, type)
108
+ boundary = "calmlens-#{SecureRandom.uuid}"
109
+ head = "--#{boundary}\r\nContent-Disposition: form-data; name=\"options\"\r\nContent-Type: application/json\r\n\r\n".b +
110
+ options_json.b + "\r\n--#{boundary}\r\nContent-Disposition: form-data; name=\"file\"\r\n".b +
111
+ "Content-Type: #{type.delete("\r\n")}\r\n\r\n".b
112
+ tail = "\r\n--#{boundary}--\r\n".b
113
+ source = @source.is_a?(String) ? head + @source + tail : FormReader.new(head, @source, tail)
114
+ [UploadBody.new(source), "multipart/form-data; boundary=#{boundary}"]
115
+ end
116
+
101
117
  # The bytes, read fully. Used only by the offline mock.
102
118
  def read_all
103
119
  return @source if @source.is_a?(String)
@@ -111,9 +127,12 @@ module CalmLens
111
127
  class IOReader
112
128
  attr_reader :size
113
129
 
114
- def initialize(io, size)
130
+ # @param bounded [Boolean] the stream goes on after `size` bytes (a slice of a larger file), so
131
+ # reading stops there without checking for more.
132
+ def initialize(io, size, bounded: false)
115
133
  @io = io
116
134
  @size = size
135
+ @bounded = bounded
117
136
  @remaining = size
118
137
  @start = (io.pos if io.respond_to?(:pos) && io.respond_to?(:seek))
119
138
  rescue IOError, SystemCallError
@@ -125,7 +144,7 @@ module CalmLens
125
144
  # A reader of `length` bytes from `offset` of this rewindable stream, positioned there.
126
145
  def slice(offset, length)
127
146
  @io.seek(@start + offset)
128
- IOReader.new(@io, length)
147
+ IOReader.new(@io, length, bounded: true)
129
148
  end
130
149
 
131
150
  def rewind
@@ -138,6 +157,8 @@ module CalmLens
138
157
  return read_unsized(length, buffer) if @size.nil?
139
158
 
140
159
  if @remaining.zero?
160
+ return length.nil? ? ''.b : buffer&.clear && nil if @bounded
161
+
141
162
  extra = @io.read(1)
142
163
  unless extra.nil? || extra.empty?
143
164
  raise Internal.invalid('The stream holds more bytes than sizeInBytes',
@@ -169,6 +190,68 @@ module CalmLens
169
190
  read(length, buffer) || raise(EOFError)
170
191
  end
171
192
  end
193
+
194
+ # An upload form around a streamed file: the form's head, the file's bytes as an {IOReader}
195
+ # reads them, then its closing boundary.
196
+ class FormReader
197
+ def initialize(head, file, tail)
198
+ @head = head
199
+ @file = file
200
+ @tail = tail
201
+ @size = file.size && (head.bytesize + file.size + tail.bytesize)
202
+ rewind_pieces
203
+ end
204
+
205
+ attr_reader :size
206
+
207
+ def rewindable? = @file.rewindable?
208
+
209
+ def rewind
210
+ @file.rewind
211
+ rewind_pieces
212
+ end
213
+
214
+ # Reads like IO#read: everything left without a length, else up to `length` bytes, nil at the end.
215
+ def read(length = nil, buffer = nil)
216
+ return (buffer ? buffer.replace(''.b) : ''.b) if length&.zero?
217
+
218
+ output = ''.b
219
+ until @pieces.empty? || (length && output.bytesize >= length)
220
+ chunk = read_piece(length && (length - output.bytesize))
221
+ chunk.nil? || chunk.empty? ? next_piece : output << chunk
222
+ end
223
+ return buffer&.clear && nil if output.empty? && length
224
+
225
+ buffer ? buffer.replace(output) : output
226
+ end
227
+
228
+ def readpartial(length, buffer = nil)
229
+ read(length, buffer) || raise(EOFError)
230
+ end
231
+
232
+ private
233
+
234
+ def rewind_pieces
235
+ @pieces = %i[head file tail]
236
+ @offset = 0
237
+ end
238
+
239
+ def next_piece
240
+ @pieces.shift
241
+ @offset = 0
242
+ end
243
+
244
+ # Up to `length` bytes (all, without one) of the current piece; empty or nil once it is used up.
245
+ def read_piece(length)
246
+ piece = @pieces.first
247
+ return @file.read(length) if piece == :file
248
+
249
+ text = piece == :head ? @head : @tail
250
+ chunk = text.byteslice(@offset, length || text.bytesize).to_s
251
+ @offset += chunk.bytesize
252
+ chunk
253
+ end
254
+ end
172
255
  end
173
256
  private_constant :UploadBody
174
257
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CalmLens
4
4
  # The gem version. The gemspec and the User-Agent header read it from here.
5
- VERSION = '0.21.0'
5
+ VERSION = '0.22.0'
6
6
  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'