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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2bd7df72a5d1e781b05bf67d5ef915c50719072e906feb6b224f69aba1073c4
4
- data.tar.gz: b81cd9b22c1e11a926cb2e39e2cace6a47dc0507ff0e96fd7e1fbaf3cba6f5cf
3
+ metadata.gz: b3b2b65d6e8ec874bb4971fd4e4568e6cc424ecce6f060c6ce796bc2eb1a1c6d
4
+ data.tar.gz: 54a14554c90bec933b2ebaf0482918f3902497c6bdff6d8204e6b374fb881aab
5
5
  SHA512:
6
- metadata.gz: aa214b8c98aff276dccce6b3a2e2d9c6f1a24a0f47387512af8262d9eba91c40973d93113df9903ffc950e8cbc906648c2fd9c3b493405d612edca465147667a
7
- data.tar.gz: 1e512158a2d93e611e8611cbbb838d260112cb9e4c672ff1e3d60b76e62ac5f247016ffe162ddfd766caa39e3b063a970c2c9b97a310941cf920cedd78741282
6
+ metadata.gz: 899100986bd5d7534a535a92f1af8c91a06fe8eadd123dfb9ef03a63d22193536c6d3bdaf72936905bee03379e88a3bb53e8197ae259fddec8b32e1d0eb951b8
7
+ data.tar.gz: 33d8fa683ba8d59117f9be22611f0ba5a12b4b2d557a4bbf61cdfd3b782f1d4186a3197bdaf3e26df8e0ff07c8ed53ca79e0bdeb84feae3d1f9ce904976ae7c6
@@ -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
- if mime.empty? || mime == 'application/octet-stream' || mime.start_with?('multipart/')
514
- raise Internal.invalid("#{mime} is not a valid upload file type. Uploads must use a concrete file MIME type " \
515
- 'that CalmLens can store and process, such as image/png, image/jpeg, ' \
516
- 'application/pdf, or text/plain.', ErrorCodes::INVALID_UPLOAD_MIME_TYPE)
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
 
@@ -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)
@@ -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
@@ -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
@@ -61,9 +61,9 @@ module CalmLens
61
61
  end
62
62
  end
63
63
 
64
- # Switches automatic updates on or off for the project's preset policy. Switching on replaces
65
- # the saved copy with the latest preset settings under the same ID; switching off pins the
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
@@ -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
- def call(options, method)
32
- check_option_keys(options, [self.class::OPERATIONS, Internal.camelize(method)].compact.join('.'))
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
- json = content_type == 'application/json' && body ? JSON.parse(body) : {}
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 its General preset policy, following automatic updates. The
891
- # mock holds no built-in preset settings: a preset's settings are `{ 'categorySettings' => {} }`
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, 'projectId' => @project_id }
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, 'projectId' => nil }
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['projectId'] != @project_id || item['automaticUpdates'] == enabled
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
- segments = path.split('/').reject(&:empty?).map { |part| URI.decode_www_form_component(part) }
1172
- _, project, identifier, resource = segments
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
- params = URI.decode_www_form(query.to_s).to_h
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' then { 'instances' => [{ 'category' => 'violence', 'qualifiers' => ['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
@@ -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
@@ -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
@@ -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.19.0'
5
+ VERSION = '0.20.0'
6
6
  end
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) or an exception.
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; `projectId` is the project whose preset policy it is, or nil for a copy.
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
- "projectId" => String?
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.19.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