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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b99a6fc15b38b4dd664a1df33e33eb8943cbd57483e16d7b7224f93cbc23d2a1
4
- data.tar.gz: 668bf0e79e7fad1838be8d4e3c0bc536a379ea0de4cf3943a97057ed5025a72a
3
+ metadata.gz: 5fd7cb8e811f68c6be7989deb980a259d4a5643bc6f4402324c64236d258661b
4
+ data.tar.gz: f5e03fc720335e9c2ee2116549ddad9407a627449aed230f31eacf9a31743a3d
5
5
  SHA512:
6
- metadata.gz: 00023a2b77c5dd78ebd7f0563ab9d152bede63882abc80c7a3e47d667f8e2d3ec54bade2c18fa4bd901167da1e8e3b7be2181491d1b3d5d1700bca568403d6ba
7
- data.tar.gz: 7e8a4bcab13c13c6278169e15a54bec41a9fc5849c366dbff1ef0208b6fceeeece777c157c52ea2286bfcca2135c64e41c8b878c6456e534fce51fd368dd61fe
6
+ metadata.gz: ea06ee7b551c7e4a4336f3fb972e0e1181a55b5a148cb89c3b281dff789efe60289db280c18ffe700f1e883f763be60b10560b6a8321773c776aa40df2c57c13
7
+ data.tar.gz: b50571100307aaa792130cf4ca1d980232185613d9376a244b7857a7f052e0e43ffcbaa584fa661e869d349c3971e9d5199a07ac053844ee3ddbaa496db5aa81
data/README.md CHANGED
@@ -69,6 +69,7 @@ New statuses and fields may appear in later API versions. The SDK returns them u
69
69
  - [Serve content from the CDN](https://calmlens.com/docs/sdk/ruby#cdn-methods), with [lenses, transforms and signed URLs](https://calmlens.com/docs/sdk/ruby/reference#cdn-urls) and [age-restricted delivery](https://calmlens.com/docs/guides/delivering-assets#age-restricted-assets).
70
70
  - [Organise assets in folders](https://calmlens.com/docs/sdk/ruby/reference#folders), [search them](https://calmlens.com/docs/guides/search) and [page through lists](https://calmlens.com/docs/sdk/ruby#pagination).
71
71
  - [Match images against hash lists](https://calmlens.com/docs/sdk/ruby/reference#hash-lists).
72
+ - Keep [upload presets](https://calmlens.com/docs/guides/upload-presets), project settings and metadata fields in your code: `upload_presets` (`list`, `each`, `ensure`, `delete`), `project_settings` (`get`, `update`) and `metadata_fields` (`list`, `ensure`, `delete`). `ensure` and `update` create or replace by key or ID, and the `revision` you read refuses a change made since.
72
73
  - [Read the events feed](https://calmlens.com/docs/sdk/ruby#pagination) and [verify webhooks in Rails or Rack](https://calmlens.com/docs/sdk/ruby#webhooks).
73
74
  - [Handle errors](https://calmlens.com/docs/sdk/ruby/reference#errors) with automatic [retries, timeouts and idempotency keys](https://calmlens.com/docs/sdk/ruby/reference#retries-and-timeouts).
74
75
  - [Type-check your code with RBS signatures](https://calmlens.com/docs/sdk/ruby/reference#types).
@@ -79,7 +80,7 @@ Every method is listed in the [Ruby SDK documentation](https://calmlens.com/docs
79
80
 
80
81
  `CalmLens::MockServerClient` from `calmlens/testing` runs the real client against an in-memory stand-in for the API and CDN, so you can test your integration offline. See [Ruby testing](https://calmlens.com/docs/sdk/ruby/testing).
81
82
 
82
- `CalmLens::TestInputs` signs test inputs with your project's test input key from the portal: `generate_text_code` turns a scenario, preset or signal into a text code, `paint_image` paints it as a test image, and `generate_media_code` gives a phrase for audio and video, so the real API returns the scenario you declared in projects with "Recognise test inputs" turned on. Submit a text code as the whole text and a test image as the whole upload.
83
+ `CalmLens::TestInputs` signs test inputs with your project's test input key from the portal: `generate_text_code` turns a scenario, preset or signal into a text code, `paint_image` paints it as a test image, and `generate_media_code` gives a phrase for audio and video, so the real API returns the scenario you declared. Submit a text code as the whole text and a test image as the whole upload.
83
84
 
84
85
  ## Links
85
86
 
@@ -15,8 +15,11 @@ module CalmLens
15
15
  # The prefix of this resource's operations in Internal::OPTION_KEYS.
16
16
  OPERATIONS = 'assets'
17
17
 
18
- SUBMIT_QUERY_KEYS = %w[name description processingOptions visibility metadata tags authorId context externalId
19
- externalUrl folderId uploadPresetId].freeze
18
+ # The asset fields an upload carries in its body: the JSON body for text, the `options` part of
19
+ # an upload form for files. They never go in the URL, which proxies and servers log, and the
20
+ # API refuses them there.
21
+ UPLOAD_OPTION_KEYS = %w[name description processingOptions visibility metadata tags authorId context externalId
22
+ externalUrl folderId uploadPresetId].freeze
20
23
  MAX_MULTIPART_IMAGE_BYTES = 20 * 1024 * 1024
21
24
  # The API's limits for one-request uploads: images up to 10 MiB (larger ones are uploaded in
22
25
  # parts), audio and video up to 50 MiB and other files up to 5,000 MiB.
@@ -276,6 +279,11 @@ module CalmLens
276
279
  # `{type:, data:}` where `data` is a binary String or an IO. An IO streams without being read into
277
280
  # memory; pass `size_in_bytes:` when its length is unknown (a pipe or socket).
278
281
  #
282
+ # Text is sent as a JSON body. Other content is sent as `multipart/form-data`: the asset's fields
283
+ # (name, description, metadata, author ID, context and so on) in its `options` part, then the
284
+ # file. The URL carries only transport values (`wait`, the content type and sizes), never the
285
+ # asset's fields.
286
+ #
279
287
  # The request carries an `Idempotency-Key` (generated unless you pass
280
288
  # `request_options: {idempotency_key:}`), so a retried upload never creates a second asset.
281
289
  #
@@ -395,13 +403,37 @@ module CalmLens
395
403
  return upload_in_parts(options, request_options, wait, mime, body) if !multipart && splits?(content, body)
396
404
 
397
405
  check_upload_size(mime, body.size) unless multipart || @session.defer_upload_checks
398
- query = options.slice(*SUBMIT_QUERY_KEYS).merge('wait' => wait, 'contentType' => content['type'],
399
- 'sizeInBytes' => body.size)
400
- query.merge!(first_part_query(options, content, mime, body)) if multipart
406
+ part_query = first_part_query(options, content, mime, body) if multipart
407
+ if content.key?('plainText')
408
+ return expect_asset(request('POST', project('assets'), query: { 'wait' => wait },
409
+ body: text_body(options, content, multipart),
410
+ idempotent: true, request_options: request_options))
411
+ end
412
+
413
+ query = { 'wait' => wait, 'contentType' => content['type'], 'sizeInBytes' => body.size }
414
+ send_form(query.merge(part_query || {}), upload_fields(options), content['type'], body, multipart, request_options)
415
+ end
416
+
417
+ # The asset fields an upload sends, without unset values.
418
+ def upload_fields(options) = options.slice(*UPLOAD_OPTION_KEYS).compact
419
+
420
+ # The JSON body of a text upload: its fields and `content`, or a first `part` with the sizes.
421
+ def text_body(options, content, multipart)
422
+ text = content.except('sizeInBytes')
423
+ return upload_fields(options).merge('content' => text) unless multipart
424
+
425
+ sizes = { 'sizeInBytes' => options['sizeInBytes'] || content['sizeInBytes'],
426
+ 'totalSizeInBytes' => options['totalSizeInBytes'] }.compact
427
+ upload_fields(options).merge('part' => text, **sizes)
428
+ end
429
+
430
+ # Sends file content, or a first part, as an upload form: the fields in its `options` part and
431
+ # the bytes in its `file` part. The query holds only transport values.
432
+ def send_form(query, fields, type, body, multipart, request_options)
433
+ form, form_type = body.to_form(JSON.generate(fields), type)
401
434
  expect_asset(request('POST', project('assets'),
402
- query: query, raw: body, idempotent: true, request_options: request_options,
403
- upload: !content.key?('plainText'),
404
- headers: { 'Content-Type' => content['type'], 'X-CalmLens-Upload' => multipart ? 'part' : 'content' }))
435
+ query: query, raw: form, idempotent: true, request_options: request_options, upload: true,
436
+ headers: { 'Content-Type' => form_type, 'X-CalmLens-Upload' => multipart ? 'part' : 'content' }))
405
437
  end
406
438
 
407
439
  # Whether content goes in parts: not text, of known size, readable again and larger than a part.
@@ -428,12 +460,9 @@ module CalmLens
428
460
  piece = ->(number) { body.slice((number - 1) * part_size, [part_size, total - ((number - 1) * part_size)].min) }
429
461
  first = piece.call(1)
430
462
  type = options.dig('content', 'type')
431
- query = options.slice(*SUBMIT_QUERY_KEYS).merge('wait' => wait, 'contentType' => type, 'sizeInBytes' => first.size,
432
- 'partNumber' => 1, 'isFinal' => false, 'totalSizeInBytes' => total)
433
- asset = expect_asset(request('POST', project('assets'),
434
- query: query, raw: first, idempotent: true, upload: true,
435
- request_options: request_options.merge(idempotency_key: key),
436
- headers: { 'Content-Type' => type, 'X-CalmLens-Upload' => 'part' }))
463
+ query = { 'wait' => wait, 'contentType' => type, 'sizeInBytes' => first.size, 'partNumber' => 1, 'isFinal' => false,
464
+ 'totalSizeInBytes' => total }
465
+ asset = send_form(query, upload_fields(options), type, first, true, request_options.merge(idempotency_key: key))
437
466
  send_remaining_parts(asset, count, piece, key, request_options)
438
467
  end
439
468
 
@@ -473,7 +502,7 @@ module CalmLens
473
502
  headers: headers))
474
503
  end
475
504
 
476
- # Keeps only the earlier posts CalmLens uses, which keeps a raw upload's query short.
505
+ # Keeps only the earlier posts CalmLens uses, which keeps an upload's body short.
477
506
  def with_used_context(options)
478
507
  return options unless options.key?('context')
479
508
 
@@ -483,7 +512,8 @@ module CalmLens
483
512
  options.merge('context' => { 'items' => ModerationContext.select_items(context['items']) })
484
513
  end
485
514
 
486
- # The query of a first multipart part: its number, whether it is the last and the declared total.
515
+ # The transport values of a first multipart part: its number, whether it is the last and the
516
+ # declared total.
487
517
  def first_part_query(options, content, mime, body)
488
518
  total = options['totalSizeInBytes']
489
519
  unless total.nil? || (total.is_a?(Integer) && total.positive?)
@@ -40,12 +40,14 @@ module CalmLens
40
40
  PERMISSION_DENIED = 'auth/permission-denied'
41
41
  UPLOAD_TOO_LARGE = 'upload/too-large'
42
42
  DOCUMENT_TOO_MANY_ITEMS = 'document/too-many-items'
43
+ MEDIA_DURATION_LIMIT = 'media/duration-limit'
43
44
  INVALID_IMAGE = 'image/invalid'
44
45
  IMAGE_TOO_LARGE = 'image/too-large'
45
46
  FREE_UPLOADS_EXHAUSTED = 'usage/free-uploads-exhausted'
46
47
  UPLOADS_PAUSED = 'usage/uploads-paused'
47
48
  UNSUPPORTED_UPLOAD_TYPE = 'upload/unsupported-type'
48
49
  INVALID_UPLOAD_PAYLOAD = 'upload-payload/invalid'
50
+ UPLOAD_OPTIONS_IN_QUERY = 'upload/options-in-query'
49
51
  USE_UPLOAD_PART = 'upload/use-upload-part'
50
52
  UPLOAD_NOT_OPEN = 'upload/not-open'
51
53
  INVALID_UPLOAD_PART_NUMBER = 'upload-part/invalid-number'
@@ -102,6 +104,14 @@ module CalmLens
102
104
  UPLOAD_PRESET_DAILY_LIMIT_REACHED = 'upload-preset/daily-limit-reached'
103
105
  UPLOAD_PRESET_ORIGIN_NOT_ALLOWED = 'upload-preset/origin-not-allowed'
104
106
  UPLOAD_PRESET_FIELD_NOT_ALLOWED = 'upload-preset/field-not-allowed'
107
+ UPLOAD_PRESET_REVISION_CONFLICT = 'upload-preset/revision-conflict'
108
+ UPLOAD_PRESET_KEY_TAKEN = 'upload-preset/key-taken'
109
+ UPLOAD_PRESET_DAILY_LIMIT_REQUIRED = 'upload-preset/daily-limit-required'
110
+ UPLOAD_PRESET_INVALID_FOLDER = 'upload-preset/invalid-folder'
111
+ UPLOAD_PRESET_INVALID_POLICY = 'upload-preset/invalid-policy'
112
+ PROJECT_SETTINGS_REVISION_CONFLICT = 'project-settings/revision-conflict'
113
+ SEARCH_ESTIMATE_CHANGED = 'search/estimate-changed'
114
+ METADATA_FIELD_NOT_FOUND = 'metadata-field/not-found'
105
115
  UPLOAD_TOKEN_INVALID = 'upload-token/invalid'
106
116
  UPLOAD_TOKEN_EXPIRED = 'upload-token/expired'
107
117
  UPLOAD_TOKEN_USED = 'upload-token/used'
@@ -153,12 +163,14 @@ module CalmLens
153
163
  'auth/permission-denied' => 403,
154
164
  'upload/too-large' => 413,
155
165
  'document/too-many-items' => 422,
166
+ 'media/duration-limit' => 422,
156
167
  'image/invalid' => 422,
157
168
  'image/too-large' => 422,
158
169
  'usage/free-uploads-exhausted' => 403,
159
170
  'usage/uploads-paused' => 402,
160
171
  'upload/unsupported-type' => 415,
161
172
  'upload-payload/invalid' => 400,
173
+ 'upload/options-in-query' => 400,
162
174
  'upload/use-upload-part' => 409,
163
175
  'upload/not-open' => 409,
164
176
  'upload-part/invalid-number' => 409,
@@ -215,6 +227,14 @@ module CalmLens
215
227
  'upload-preset/daily-limit-reached' => 429,
216
228
  'upload-preset/origin-not-allowed' => 403,
217
229
  'upload-preset/field-not-allowed' => 400,
230
+ 'upload-preset/revision-conflict' => 409,
231
+ 'upload-preset/key-taken' => 409,
232
+ 'upload-preset/daily-limit-required' => 400,
233
+ 'upload-preset/invalid-folder' => 400,
234
+ 'upload-preset/invalid-policy' => 400,
235
+ 'project-settings/revision-conflict' => 409,
236
+ 'search/estimate-changed' => 409,
237
+ 'metadata-field/not-found' => 404,
218
238
  'upload-token/invalid' => 401,
219
239
  'upload-token/expired' => 401,
220
240
  'upload-token/used' => 409,
@@ -61,6 +61,15 @@ module CalmLens
61
61
  Error.parse_retry_after(headers['retry-after'])
62
62
  end
63
63
 
64
+ # @return [Boolean] true when the SDK raised this error itself, with no HTTP response: before
65
+ # sending (an invalid argument or option, or content it checks first, such as
66
+ # `upload/unsupported-type`), or because no response arrived (a timeout or a connection
67
+ # failure). The code is the one the API would use; `status` and `request_id` are nil, so
68
+ # branch on `code`.
69
+ def local?
70
+ status.nil?
71
+ end
72
+
64
73
  # @return [Boolean] true when the resource does not exist.
65
74
  def not_found?
66
75
  status == 404 || code.end_with?('/not-found')
@@ -62,6 +62,9 @@ module CalmLens
62
62
  'lenses.list' => %w[cursor pageSize sort].freeze,
63
63
  'lenses.retrieve' => %w[lensId].freeze,
64
64
  'lenses.update' => %w[active description lensId name transform type].freeze,
65
+ 'metadataFields.delete' => %w[fieldId revision].freeze,
66
+ 'metadataFields.ensure' => %w[fields revision].freeze,
67
+ 'metadataFields.list' => %w[].freeze,
65
68
  'policies.assign' => %w[policyId].freeze,
66
69
  'policies.create' => %w[description name presetId settings].freeze,
67
70
  'policies.delete' => %w[policyId].freeze,
@@ -73,10 +76,16 @@ module CalmLens
73
76
  'policies.setAutomaticUpdates' => %w[enabled policyId presetVersion revision].freeze,
74
77
  'policies.update' => %w[description name policyId revision settings].freeze,
75
78
  'policies.upgrade' => %w[policyId presetVersion revision].freeze,
79
+ 'projectSettings.get' => %w[].freeze,
80
+ 'projectSettings.update' => %w[revision searchIndexingBudgetUnits settings].freeze,
76
81
  'text.classify' => %w[
77
82
  authorId context description externalId externalUrl folderId metadata name processingOptions tags text
78
83
  uploadPresetId visibility
79
- ].freeze
84
+ ].freeze,
85
+ 'uploadPresets.delete' => %w[uploadPresetId].freeze,
86
+ 'uploadPresets.each' => %w[cursor pageSize sort].freeze,
87
+ 'uploadPresets.ensure' => %w[uploadPresets].freeze,
88
+ 'uploadPresets.list' => %w[cursor pageSize sort].freeze
80
89
  }.freeze
81
90
 
82
91
  # The option keys each method sends as null when given nil; any other option given as nil
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CalmLens
4
+ # The project's upload presets: the rules for uploads made from browsers and apps. {#ensure}
5
+ # creates or replaces presets by `key`, for applications that declare theirs in code or
6
+ # configuration. Reach it through {ServerClient#upload_presets}.
7
+ class UploadPresets < Resource
8
+ # The prefix of this resource's operations in Internal::OPTION_KEYS.
9
+ OPERATIONS = 'uploadPresets'
10
+
11
+ # Lists one page of the project's upload presets, oldest first unless `sort` says otherwise.
12
+ # @option options [String] :cursor the previous page's `nextCursor`
13
+ # @option options [Integer] :page_size 1 to 100, default 50
14
+ # @option options [String] :sort `createdAt` and `id`, comma-separated, descending when prefixed
15
+ # with `-`; `createdAt` by default
16
+ # @return [Hash] a cursor page: items, nextCursor and hasMore.
17
+ def list(options = {})
18
+ call(options, __method__) do |api, request|
19
+ cursor_page(request('GET', project('upload-presets'), query: api, request_options: request), 'upload preset')
20
+ end
21
+ end
22
+
23
+ # Iterates every upload preset of the project, fetching pages as needed.
24
+ # @param options [Hash] the options of {#list}; `cursor` sets the starting position.
25
+ # @yieldparam upload_preset [Hash]
26
+ # @return [Enumerator, nil] an Enumerator when no block is given.
27
+ def each(options = {}, &block)
28
+ return enum_for(:each, options) unless block
29
+
30
+ each_item(options, __method__, method(:list), &block)
31
+ end
32
+
33
+ # Creates each preset, or replaces it with the definition sent, by `key`. Fields left out take
34
+ # their defaults; an unchanged preset keeps its revision. Every preset is checked before any is
35
+ # saved. Send each preset's `revision` (0 for one that must not exist yet) to refuse a change
36
+ # made since with `upload-preset/revision-conflict`.
37
+ # @option options [Array<Hash>] :upload_presets `[{key:, name:, revision:, access:, enabled:, ...}]`,
38
+ # at most 50
39
+ # @return [Hash] `uploadPresets`: the stored presets, in the order sent.
40
+ def ensure(options)
41
+ call(options, __method__) do |api, request|
42
+ request('PUT', project('upload-presets'), body: api, request_options: request)
43
+ end
44
+ end
45
+
46
+ # Deletes an upload preset. Browsers can no longer upload through it; assets already uploaded stay.
47
+ # @option options [String] :upload_preset_id
48
+ # @return [nil]
49
+ def delete(options)
50
+ call(options, __method__) do |api, request|
51
+ request('DELETE', "/upload-presets/#{Internal.segment(api['uploadPresetId'])}", request_options: request)
52
+ nil
53
+ end
54
+ end
55
+ end
56
+
57
+ # The project's settings, with defaults filled in. {#update} replaces them: settings left out
58
+ # return to their defaults. Send the `revision` you read to refuse a change made since with
59
+ # `project-settings/revision-conflict`. Reach it through {ServerClient#project_settings}.
60
+ class ProjectSettings < Resource
61
+ # The prefix of this resource's operations in Internal::OPTION_KEYS.
62
+ OPERATIONS = 'projectSettings'
63
+
64
+ # @return [Hash] `projectId`, `revision` and `settings`.
65
+ def get(options = {})
66
+ call(options, __method__) { |_api, request| request('GET', project('settings'), request_options: request) }
67
+ end
68
+
69
+ # Replaces the project's settings; unchanged settings keep their revision. The project's policy
70
+ # and metadata fields have their own methods.
71
+ # @option options [Integer] :revision the revision you read
72
+ # @option options [Hash] :settings the complete settings
73
+ # @option options [Numeric] :search_indexing_budget_units needed when turning on
74
+ # `aiMediaSearchEnabled`: the most indexing units you accept for existing media
75
+ # @return [Hash] `projectId`, `revision` and `settings`.
76
+ def update(options)
77
+ call(options, __method__) { |api, request| request('PUT', project('settings'), body: api, request_options: request) }
78
+ end
79
+ end
80
+
81
+ # The project's metadata fields, in display order. They share the project settings' `revision`;
82
+ # {#ensure} creates or replaces fields by ID and keeps the others. Reach it through
83
+ # {ServerClient#metadata_fields}.
84
+ class MetadataFields < Resource
85
+ # The prefix of this resource's operations in Internal::OPTION_KEYS.
86
+ OPERATIONS = 'metadataFields'
87
+
88
+ # @return [Hash] `projectId`, `revision` and `fields`.
89
+ def list(options = {})
90
+ call(options, __method__) { |_api, request| request('GET', project('metadata-fields'), request_options: request) }
91
+ end
92
+
93
+ # Creates each field, or replaces it by ID. Other fields are kept; new ones are added at the end.
94
+ # @option options [Integer] :revision the revision you read
95
+ # @option options [Array<Hash>] :fields `[{id:, title:, type:, icon:, color:, description:}]`
96
+ # @return [Hash] `projectId`, `revision` and `fields`.
97
+ def ensure(options)
98
+ call(options, __method__) { |api, request| request('PUT', project('metadata-fields'), body: api, request_options: request) }
99
+ end
100
+
101
+ # Deletes a field definition; assets keep the metadata values they hold.
102
+ # @option options [String] :field_id
103
+ # @option options [Integer] :revision the revision you read
104
+ # @return [nil]
105
+ def delete(options)
106
+ call(options, __method__) do |api, request|
107
+ request('DELETE', project("metadata-fields/#{Internal.segment(api['fieldId'])}"),
108
+ query: without(api, 'fieldId'), request_options: request)
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end
@@ -51,6 +51,12 @@ module CalmLens
51
51
  attr_reader :cdn
52
52
  # @return [Lenses]
53
53
  attr_reader :lenses
54
+ # @return [UploadPresets]
55
+ attr_reader :upload_presets
56
+ # @return [ProjectSettings]
57
+ attr_reader :project_settings
58
+ # @return [MetadataFields]
59
+ attr_reader :metadata_fields
54
60
 
55
61
  # @param api_key [String] a project API key. Keep it on your server.
56
62
  # @param project_id [String]
@@ -100,6 +106,9 @@ module CalmLens
100
106
  @events = Events.new(@session)
101
107
  @cdn = CDN.new(@session)
102
108
  @lenses = Lenses.new(@session)
109
+ @upload_presets = UploadPresets.new(@session)
110
+ @project_settings = ProjectSettings.new(@session)
111
+ @metadata_fields = MetadataFields.new(@session)
103
112
  end
104
113
 
105
114
  # @return [String]