calmlens 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +222 -0
- data/lib/calmlens/assets.rb +328 -0
- data/lib/calmlens/cdn.rb +63 -0
- data/lib/calmlens/error_codes.rb +133 -0
- data/lib/calmlens/errors.rb +154 -0
- data/lib/calmlens/events.rb +46 -0
- data/lib/calmlens/folders.rb +63 -0
- data/lib/calmlens/internal.rb +141 -0
- data/lib/calmlens/lenses.rb +16 -0
- data/lib/calmlens/resource.rb +51 -0
- data/lib/calmlens/response.rb +82 -0
- data/lib/calmlens/server_client.rb +134 -0
- data/lib/calmlens/session.rb +246 -0
- data/lib/calmlens/testing/fake_api.rb +570 -0
- data/lib/calmlens/testing.rb +141 -0
- data/lib/calmlens/text.rb +29 -0
- data/lib/calmlens/transforms.rb +104 -0
- data/lib/calmlens/transport.rb +129 -0
- data/lib/calmlens/upload_body.rb +149 -0
- data/lib/calmlens/upload_tokens.rb +52 -0
- data/lib/calmlens/version.rb +6 -0
- data/lib/calmlens/webhooks.rb +122 -0
- data/lib/calmlens.rb +26 -0
- data/sig/calmlens.rbs +273 -0
- data/sig/error_codes.rbs +67 -0
- data/sig/types.rbs +682 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 86b8ba4a942285cf19fd32a128390b7dc9c19c8317b3e6d6b3d03d42306d7811
|
|
4
|
+
data.tar.gz: fad92edc638c5a38cc99a60687251c7c20b13f41899a8f123182d0ac341b1a8c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cd376e3647215281732a95ff55bd91b86155bad65782e632c264f5c0f43ceb61fa3b8db56346951c0dd4b98f214ebe309f0e70b447dc9ab6af237b2e91e6dcb1
|
|
7
|
+
data.tar.gz: a636fe02abe826b641223ef4d1c712a86942116ea13914af934bc8800b4b99ffde9ceae2501e58734e389b0bc09307aa468c7fedd0f6467cd542e4d23a7fd0f4
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CalmLens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# CalmLens Ruby SDK
|
|
2
|
+
|
|
3
|
+
Ruby 3.1+ server-side client for CalmLens moderation, uploads, events, webhooks and CDN delivery. It uses only the standard library (Net::HTTP, JSON, OpenSSL). Calls accept keyword arguments in snake_case (or the API's camelCase); responses are Hashes with the API's camelCase string keys.
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
require 'calmlens'
|
|
7
|
+
client = CalmLens::ServerClient.new(api_key: ENV.fetch('CALMLENS_API_KEY'), project_id: ENV.fetch('CALMLENS_PROJECT_ID'))
|
|
8
|
+
result = client.assets.moderate(name: 'text', content: {plain_text: 'Hello world', type: 'text/plain'})
|
|
9
|
+
asset = client.assets.create(name: 'note.txt', content: {data: 'Hello', type: 'text/plain'})
|
|
10
|
+
client.close
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Install from RubyGems: `gem install calmlens`, or add `gem "calmlens"` to your Gemfile. The gem has no runtime dependencies and ships YARD documentation and RBS signatures in `sig/` (see [Types](#types)).
|
|
14
|
+
|
|
15
|
+
Resources cover every API operation: assets create/moderate/search/list/each/retrieve/wait_for_decision/update/retry/delete_content/delete/move/list_children/each_child/upload_part/create_signed_url; folders list/create/retrieve/update/move/delete; text moderation; events list/each/each_page; CDN URL building and serving; and `ensure_lens`/`ensure_lenses`. `CalmLens::VERSION` holds the gem version.
|
|
16
|
+
|
|
17
|
+
## Options
|
|
18
|
+
|
|
19
|
+
Every option name may be snake_case or camelCase, at any depth, so options added to the API later work without an SDK update (`processing_options: {features: {face_detection: {enabled: true, age_estimation: true}}}` is sent as `processingOptions.features.faceDetection.ageEstimation`). Keys inside `metadata` are yours and are sent unchanged.
|
|
20
|
+
|
|
21
|
+
Any method also accepts `request_options:`:
|
|
22
|
+
|
|
23
|
+
| Key | Meaning |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `idempotency_key` | The `Idempotency-Key` for an upload. The SDK generates one when you omit it. |
|
|
26
|
+
| `timeout` | Seconds allowed for each socket read or write for this call. |
|
|
27
|
+
| `open_timeout` | Seconds allowed to open a connection for this call. |
|
|
28
|
+
| `max_retries` | Retries for this call (see below). |
|
|
29
|
+
|
|
30
|
+
## Types
|
|
31
|
+
|
|
32
|
+
`sig/` holds RBS signatures for every public method, with record types for each option Hash and response (in `sig/types.rbs`): `CalmLens::asset` (with `asset_processing`, `asset_moderation` and `asset_content`), `classification_report` (categories, qualifiers, labels, media segments and `features` results), `processing_options_input`, `folder`, `lens`, `event`, `events_page`, `asset_page`, `asset_search_response`, `signed_url`, `webhook_event_data` and the rest. Steep and other RBS tools check your calls and the response keys you read:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
asset = client.text.moderate(text: 'Hello', processing_options: { features: { face_detection: { enabled: true } } })
|
|
36
|
+
asset['moderation']['status'] # CalmLens::asset_moderation_status
|
|
37
|
+
asset.dig('moderation', 'report', 'features', 'faceDetection', 'faces') # Array[CalmLens::detected_face]?
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The signatures describe snake_case Symbol option keys; camelCase and String keys still work at runtime but are not typed. Results whose fields depend on `status` (category, qualifier and feature results) are typed as one record with those fields optional: check `status`, then read `score` or `reason`. `metadata` values and API error `details` are any JSON (`CalmLens::json_value`).
|
|
41
|
+
|
|
42
|
+
## Moderation results
|
|
43
|
+
|
|
44
|
+
`assets.moderate` and `text.moderate` return the asset, whose state is in three parts:
|
|
45
|
+
|
|
46
|
+
- `processing`: `status` is `uploading`, `processing`, `done` or `failed` (retry with `assets.retry`), with `updatedAt` and, during a multipart upload, `uploadPartsReceived`.
|
|
47
|
+
- `moderation`: `status` is the decision in force, `pending`, `approved` or `rejected` (a reviewer's decision wins over the automated one). `action` is what your policy did with flagged content (`alert`, `hide` or `purge`), or nil when nothing was flagged; it stays after a reviewer decides. `note` explains an automated rejection; `reviewerAction`, `reviewerNote`, `reviewerUserId` and `reviewedAt` describe a review; `decidedAt` is when the decision in force was made; `report` is the moderation report.
|
|
48
|
+
- `content`: `status` is `temporary` (held until processing finishes), `stored` (kept with `keep_content_after_processing: true`) or `removed`, with `removalReason` (`policy`, `retention`, `requested`, `parent-removed` or `disposed`). It also holds `format` (MIME type), `extension`, `sizeInBytes`, `checksums`, `image`, `previewImage`, `extractedText` and `safetyHold`.
|
|
49
|
+
|
|
50
|
+
Publish only when `asset['moderation']['status']` is `approved` and `asset['moderation']['action']` is nil. New statuses, visibilities and fields may appear in later API versions: the SDK returns them unchanged, so treat any value you do not recognise as not ready.
|
|
51
|
+
|
|
52
|
+
### Waiting for a decision
|
|
53
|
+
|
|
54
|
+
`assets.wait_for_decision` waits for a queued asset's decision and returns the asset once `moderation.status` is `approved` or `rejected`, or `processing.status` is `failed`. It retrieves the asset every few seconds and raises `CalmLens::TimeoutError` with code `sdk/timeout` if there's no decision within `timeout:` seconds (default 60); `details` holds `'assetId'` and the last `'processingStatus'` and `'moderationStatus'`. Use it after `assets.create`, or with an asset ID you stored earlier. Webhooks report decisions without polling.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
queued = client.assets.create(name: 'photo.jpg', content: {data: File.open('photo.jpg', 'rb'), type: 'image/jpeg'})
|
|
58
|
+
decided = client.assets.wait_for_decision(asset_id: queued['id'], timeout: 30)
|
|
59
|
+
hide_photo(decided['id']) if decided['moderation']['status'] == 'rejected'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`timeout:` covers the whole wait; `request_options: {timeout:}` still limits each request. The call only sleeps and sends requests, so you can stop it early by stopping its thread.
|
|
63
|
+
|
|
64
|
+
## Uploads and delivery
|
|
65
|
+
|
|
66
|
+
Content is `{type:, plain_text:}`, `{type:, base64:}` or `{type:, data:}`, where `data` is a binary String or an IO. An IO is streamed with a `Content-Length` and never read into memory; a File, Tempfile or StringIO knows its size, and other streams (pipes, sockets) need `size_in_bytes:`. A stream that ends early or holds more bytes than declared fails with `upload/invalid-payload`.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
File.open('video.mp4', 'rb') do |file|
|
|
70
|
+
client.assets.create(name: 'video.mp4', content: {data: file, type: 'video/mp4'})
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Multipart uploads start with `part:` on `create`/`moderate` and continue with `upload_part`. The queue mode chosen at the start is saved with the upload, so later parts can come from another process; pass `queue:` to `upload_part` only to override it. Pass `total_size_in_bytes:` at the start when you know the file size. See [Multipart uploads](https://calmlens.com/docs/guides/multipart-uploads).
|
|
75
|
+
|
|
76
|
+
`cdn.serve` fetches content with the API key. With a block it yields a `CalmLens::Response` whose body streams; without one it returns the response with the body read. Error statuses are returned, not raised.
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
File.open('photo.jpg', 'wb') do |file|
|
|
80
|
+
client.cdn.serve(asset_identifier: asset_id) do |response|
|
|
81
|
+
raise "CDN returned #{response.status}" unless response.success?
|
|
82
|
+
response.each_chunk { |chunk| file.write(chunk) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Use `create_signed_url` to deliver private content to browsers without your key.
|
|
88
|
+
|
|
89
|
+
`cdn.get_asset_url`, `cdn.serve` and `create_signed_url` accept a `lens:`, a `transform:`, or both (the lens runs first). A transform is a selector string or structured steps (one Hash or an Array), serialized with sorted option keys:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
client.cdn.get_asset_url(asset_identifier: asset_id, transform: 'imageTransform[format=png,width=1024]')
|
|
93
|
+
client.cdn.get_asset_url(asset_identifier: asset_id, transform: {type: 'imageTransform', options: {width: 1024, format: 'png'}})
|
|
94
|
+
# => ".../content/imageTransform[format=png,width=1024]" in both cases
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Upload presets
|
|
98
|
+
|
|
99
|
+
Pass `upload_preset_id:` to `assets.create` or `assets.moderate` to apply an upload preset's folder, visibility, file types and size limit. To let a browser upload through a preset that requires tokens, sign a single-use token on your server. Signing happens locally with your API key and makes no request.
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
signed = client.upload_tokens.sign(
|
|
103
|
+
upload_preset_id: 'your-upload-preset-id',
|
|
104
|
+
external_id: 'post-42', # optional: the uploaded asset gets this ID
|
|
105
|
+
expires_in_seconds: 600 # optional: 1 to 3600, 600 by default
|
|
106
|
+
)
|
|
107
|
+
# signed['token'], signed['expiresAt'] (milliseconds since the Unix epoch)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
See [Upload presets](https://calmlens.com/docs/guides/upload-presets). `CalmLens::MockServerClient` signs tokens with its own key.
|
|
111
|
+
|
|
112
|
+
## Pagination
|
|
113
|
+
|
|
114
|
+
`assets.each`, `assets.each_child`, `events.each` and `events.each_page` fetch pages as you iterate and return an Enumerator without a block, so `client.assets.each(filter: 'moderation.status==approved').lazy.first(5)` stops after the pages it needs.
|
|
115
|
+
|
|
116
|
+
## Events feed
|
|
117
|
+
|
|
118
|
+
`events.list` without `after` returns **no events** and a cursor for the current time. Store it, then read on from it; persist each page's cursor once its events are handled.
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
cursor = client.events.list['cursor'] # start from now
|
|
122
|
+
# ...later, and on every poll:
|
|
123
|
+
client.events.each_page(after: cursor, limit: 50) do |page|
|
|
124
|
+
page['events'].each { |event| handle(event) } # event['type'], event['facts'], event['assetId']
|
|
125
|
+
cursor = page['cursor'] # persist it
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each event carries `facts`, an array of `{'key', 'value', 'url'?}` entries registered for its `type`. An unknown cursor raises `events/invalid-cursor`.
|
|
130
|
+
|
|
131
|
+
## Webhooks
|
|
132
|
+
|
|
133
|
+
Verify the **raw** request body, never re-serialized JSON. `CalmLens.parse_webhook_event` verifies the signature and timestamp, then parses the delivery into a `CalmLens::WebhookEvent` (`id`, `type`, `created_at`, `project_id`, `data`, plus `asset_id`, `facts` and `test?`). It raises `CalmLens::WebhookVerificationError` with code `sdk/invalid-webhook-signature` or `sdk/invalid-webhook-payload`. Passing a parsed Hash instead of the body raises `CalmLens::InvalidArgumentError`.
|
|
134
|
+
|
|
135
|
+
Rails:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
class CalmLensWebhooksController < ActionController::API
|
|
139
|
+
def create
|
|
140
|
+
event = CalmLens.parse_webhook_event(
|
|
141
|
+
payload: request.raw_post,
|
|
142
|
+
signature: request.headers[CalmLens::WEBHOOK_SIGNATURE_HEADER],
|
|
143
|
+
timestamp: request.headers[CalmLens::WEBHOOK_TIMESTAMP_HEADER],
|
|
144
|
+
secret: ENV.fetch('CALMLENS_WEBHOOK_SECRET')
|
|
145
|
+
)
|
|
146
|
+
# Acknowledge first; do the work in a job. Skip a delivery ID you have already queued.
|
|
147
|
+
CalmLensEventJob.perform_later(request.headers[CalmLens::WEBHOOK_DELIVERY_HEADER], event.to_h)
|
|
148
|
+
head :no_content
|
|
149
|
+
rescue CalmLens::WebhookVerificationError
|
|
150
|
+
head :bad_request
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Sinatra (or any Rack app):
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
post '/webhooks/calmlens' do
|
|
159
|
+
request.body.rewind
|
|
160
|
+
event = CalmLens.parse_webhook_event(
|
|
161
|
+
payload: request.body.read,
|
|
162
|
+
signature: request.env['HTTP_X_CALMLENS_SIGNATURE'],
|
|
163
|
+
timestamp: request.env['HTTP_X_CALMLENS_TIMESTAMP'],
|
|
164
|
+
secret: ENV.fetch('CALMLENS_WEBHOOK_SECRET')
|
|
165
|
+
)
|
|
166
|
+
enqueue(request.env['HTTP_X_CALMLENS_DELIVERY'], event)
|
|
167
|
+
204
|
|
168
|
+
rescue CalmLens::WebhookVerificationError
|
|
169
|
+
400
|
|
170
|
+
end
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The header names are exported as `CalmLens::WEBHOOK_SIGNATURE_HEADER`, `WEBHOOK_TIMESTAMP_HEADER` and `WEBHOOK_DELIVERY_HEADER`. Retries of one delivery repeat its `x-calmlens-delivery` ID, so use it to skip duplicates. Deliveries signed more than `CalmLens::WEBHOOK_SIGNATURE_TOLERANCE_SECONDS` (300) from your clock fail; change it with `tolerance_seconds:`. `CalmLens.verify_webhook_signature` returns true or false instead of raising, and `CalmLens.create_webhook_signature_header(payload:, timestamp:, secret:)` signs a body, for example to test your receiver.
|
|
174
|
+
|
|
175
|
+
## Errors
|
|
176
|
+
|
|
177
|
+
Every error the SDK raises is a `CalmLens::Error` with `code`, `message`, `details`, `status` (nil without a response), `request_id` (the `X-Request-Id` header, for support), `headers`, `retry_after` (seconds from `Retry-After`) and the original exception in `cause`. Helpers: `not_found?` and `retryable?`. Compare codes with the constants in `CalmLens::ErrorCodes` (API codes, generated from the API's list, with their HTTP statuses in `ErrorCodes::STATUSES`) and `CalmLens::SdkErrorCodes`.
|
|
178
|
+
|
|
179
|
+
| Class | When |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `CalmLens::APIError` and its subclasses `BadRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `ServerError` | The API answered with an error status. |
|
|
182
|
+
| `CalmLens::ConnectionError` (`sdk/transport-error`), `CalmLens::TimeoutError` (`sdk/timeout`) | No complete response arrived, or `assets.wait_for_decision` had no decision in time. |
|
|
183
|
+
| `CalmLens::InvalidResponseError` (`sdk/invalid-response`, `sdk/response-too-large`) | A successful response could not be read, or was larger than 32 MiB. |
|
|
184
|
+
| `CalmLens::InvalidArgumentError` (`sdk/invalid-argument`, or an `upload/*` code for payload checks the API also makes) | An argument was rejected before sending. |
|
|
185
|
+
| `CalmLens::ConfigurationError` (`sdk/invalid-config`) | `ServerClient.new` received an invalid setting, such as a malformed `base_url`. |
|
|
186
|
+
| `CalmLens::WebhookVerificationError` | A webhook delivery failed verification. |
|
|
187
|
+
|
|
188
|
+
## Retries and timeouts
|
|
189
|
+
|
|
190
|
+
Requests that are safe to repeat are retried up to `max_retries:` times (default 2): GET and HEAD, and uploads, which carry an `Idempotency-Key` reused on every retry so CalmLens runs them once and replays the saved response. Connection failures, timeouts, `429`, `502`, `503`, `504` and `idempotency/request-in-progress` are retried with jittered exponential backoff (`retry_base_delay:`, default 0.5 seconds, at most 8), waiting for `Retry-After` when the API sends one. Other writes are never retried. A streamed upload is retried only when its IO can seek back.
|
|
191
|
+
|
|
192
|
+
`timeout:` (default 120 seconds) limits each socket read or write, not a whole transfer, so large uploads and downloads are not cut off. `open_timeout:` (default 10 seconds) limits connecting. Both can be set per call with `request_options:`.
|
|
193
|
+
|
|
194
|
+
## Connections
|
|
195
|
+
|
|
196
|
+
Reuse one client: it keeps up to 8 idle keep-alive connections per host, is safe to share between threads, never follows redirects, and after `fork` (Puma, Unicorn, Resque) the child opens its own connections instead of sharing the parent's. Call `close` when finished. Requests identify themselves as `calmlens-ruby/<version> (ruby <version>)`. `inspect` never shows the API key.
|
|
197
|
+
|
|
198
|
+
## Offline testing
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
require 'calmlens/testing'
|
|
202
|
+
mock = CalmLens::MockServerClient.new
|
|
203
|
+
asset = mock.assets.moderate(name: 'note', content: {plain_text: 'hello', type: 'text/plain'},
|
|
204
|
+
processing_options: {keep_content_after_processing: true})
|
|
205
|
+
mock.assets.retrieve(asset_id: asset['id']).dig('moderation', 'status') # => "approved"
|
|
206
|
+
mock.assets.list(filter: 'moderation.status==approved')['items'].length # => 1
|
|
207
|
+
mock.cdn.serve(asset_identifier: asset['id']).body # => "hello"
|
|
208
|
+
|
|
209
|
+
mock.enqueue(status: 503, body: {code: 'server/unavailable', message: 'Busy'}) # answers the next request
|
|
210
|
+
mock.enqueue_error(Net::ReadTimeout.new) # the next request raises CalmLens::TimeoutError
|
|
211
|
+
mock.enqueue_event(type: 'asset.approved', asset_id: asset['id']) # joins the events feed
|
|
212
|
+
|
|
213
|
+
request = mock.requests.last
|
|
214
|
+
request.method # => "GET"
|
|
215
|
+
request.path # => "/assets/project-1/<id>/content" (relative to base_url or cdn_base_url)
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`MockServerClient` runs the real client's validation, encoding, retries and error handling, then answers from an in-memory stand-in for the API and CDN. Uploads create assets you can retrieve, list, search, move and delete, and `assets.wait_for_decision` checks the stored asset's processing and moderation status; folders, multipart uploads, idempotency keys, lenses and the events feed behave like the API's; errors carry the API's codes, HTTP statuses and body shape; and `cdn.serve` returns stored bytes with real headers. Like the API, content is removed (`removalReason` `retention`) once processing is `done` unless the upload sets `keep_content_after_processing: true`. List filters support `==` and `!=` (with `*` wildcards) joined by `;` and `,`, on top-level and nested names such as `moderation.status`, `processing.status`, `content.status` and `content.format`; other RSQL operators raise rather than being ignored. It does not classify content: `moderate` gives `analyzed_outcome:` and `create` gives `queued_outcome:`. An outcome is `pending` (still processing), `approved` or `rejected` (processing done with that decision) or `failed` (processing failed, retryable). `analyzed_outcome:` can also be fields to deep-merge into the asset, such as `{'moderation' => {'status' => 'rejected', 'action' => 'hide'}}` (the outcome follows `moderation.status`), or a callable that receives the asset and returns either. Change records through `mock.state` to arrange a test. Queued responses and errors are used first, in order.
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
Native tests (Ruby 3.1+, Bundler 2.6+): `bundle install && bundle exec ruby -Ilib -e 'Dir["test/*_test.rb"].each { |file| require_relative file }'`, and lint with `bundle exec rubocop`. `lib/calmlens/error_codes.rb` and the files in `testdata/` are generated by `bun packages/sdk-contract/generate.ts` from the repository root. Container/backend checks: `bun run test:sdk:e2e`. Type-check the signatures with `gem install steep && steep check` (see `Steepfile`) and `rbs -I sig validate`. Release by setting `CalmLens::VERSION` in `lib/calmlens/version.rb`.
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# Uploads, moderation and management of assets. Reach it through {ServerClient#assets}.
|
|
5
|
+
#
|
|
6
|
+
# Every method takes an options Hash (or keyword arguments) whose names may be snake_case
|
|
7
|
+
# (`asset_id:`) or the API's camelCase (`'assetId'`); keys inside `metadata` are sent unchanged.
|
|
8
|
+
# Any method also accepts `request_options: { idempotency_key:, timeout:, open_timeout:, max_retries: }`.
|
|
9
|
+
# Results are Hashes with the API's camelCase string keys. Fields and enum values added to the API
|
|
10
|
+
# later appear as they are, so compare `moderation.status` and similar values with known strings
|
|
11
|
+
# and treat anything else as not ready.
|
|
12
|
+
class Assets < Resource
|
|
13
|
+
SUBMIT_QUERY_KEYS = %w[name description processingOptions visibility metadata externalId externalUrl
|
|
14
|
+
folderId uploadPresetId].freeze
|
|
15
|
+
MIN_MULTIPART_IMAGE_BYTES = 10 * 1024 * 1024
|
|
16
|
+
MAX_MULTIPART_IMAGE_BYTES = 20 * 1024 * 1024
|
|
17
|
+
# Moderation statuses that carry a decision; {#wait_for_decision} returns once an asset has one,
|
|
18
|
+
# or once its processing status is in {FINISHED_PROCESSING_STATUSES}.
|
|
19
|
+
DECIDED_MODERATION_STATUSES = %w[approved rejected].freeze
|
|
20
|
+
# Processing statuses after which no decision will arrive without a retry.
|
|
21
|
+
FINISHED_PROCESSING_STATUSES = %w[failed].freeze
|
|
22
|
+
DECISION_POLL_FIRST_DELAY = 0.5
|
|
23
|
+
DECISION_POLL_MAX_DELAY = 5.0
|
|
24
|
+
DEFAULT_DECISION_TIMEOUT = 60
|
|
25
|
+
|
|
26
|
+
# Searches assets by meaning or keyword.
|
|
27
|
+
# @option options [String] :query
|
|
28
|
+
# @option options [String] :mode `hybrid`, `semantic` or `keyword`
|
|
29
|
+
# @option options [Integer] :limit
|
|
30
|
+
# @return [Hash] `{'query', 'items' => [{'asset', 'score', 'matchedFields'}], 'semanticStatus'}`
|
|
31
|
+
def search(options = {})
|
|
32
|
+
call(options) { |api, request| request('GET', project('assets/search'), query: api, request_options: request) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Lists one page of assets.
|
|
36
|
+
# @option options [String] :cursor from the previous page
|
|
37
|
+
# @option options [Integer] :page_size
|
|
38
|
+
# @option options [String] :filter an RSQL filter such as `moderation.status==approved`
|
|
39
|
+
# @option options [Array<Hash>] :ordering `[{field: 'createdAt', direction: 'DESC'}]`
|
|
40
|
+
# @return [Hash] `{'items', 'total', 'hasMore', 'cursor', 'pageSize'}`
|
|
41
|
+
def list(options = {})
|
|
42
|
+
call(options) do |api, request|
|
|
43
|
+
request('GET', project('assets/page'), query: api, request_options: request)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Iterates every asset matching the options, fetching pages as needed.
|
|
48
|
+
# @param options [Hash] the options of {#list}; `cursor` sets the starting position.
|
|
49
|
+
# @yieldparam asset [Hash]
|
|
50
|
+
# @return [Enumerator, nil] an Enumerator when no block is given.
|
|
51
|
+
def each(options = {}, &block)
|
|
52
|
+
return enum_for(:each, options) unless block
|
|
53
|
+
|
|
54
|
+
api, request = call(options) { |*split| split }
|
|
55
|
+
listing = pages(api['cursor']) { |index| list(api.merge('cursor' => index, 'request_options' => request)) }
|
|
56
|
+
listing.each { |page| page['items'].each(&block) }
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Lists one page of an asset's children, such as frames extracted from a video.
|
|
61
|
+
# @option options [String] :asset_id
|
|
62
|
+
# @return [Hash] a page like {#list}
|
|
63
|
+
def list_children(options)
|
|
64
|
+
call(options) do |api, request|
|
|
65
|
+
query = without(api, 'assetId')
|
|
66
|
+
request('GET', "/assets/#{Internal.segment(api['assetId'])}/children", query: query, request_options: request)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Iterates every child of an asset, fetching pages as needed.
|
|
71
|
+
# @param options [Hash] the options of {#list_children}
|
|
72
|
+
# @yieldparam asset [Hash]
|
|
73
|
+
# @return [Enumerator, nil] an Enumerator when no block is given.
|
|
74
|
+
def each_child(options, &block)
|
|
75
|
+
return enum_for(:each_child, options) unless block
|
|
76
|
+
|
|
77
|
+
api, request = call(options) { |*split| split }
|
|
78
|
+
listing = pages(api['cursor']) { |index| list_children(api.merge('cursor' => index, 'request_options' => request)) }
|
|
79
|
+
listing.each { |page| page['items'].each(&block) }
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @option options [String] :asset_id
|
|
84
|
+
# @return [Hash] the asset
|
|
85
|
+
def retrieve(options)
|
|
86
|
+
call(options) do |api, request|
|
|
87
|
+
request('GET', "/assets/#{Internal.segment(api['assetId'])}", request_options: request)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Waits until an asset's `moderation.status` is `approved` or `rejected`, or its
|
|
92
|
+
# `processing.status` is `failed`, and returns the asset. It retrieves the asset, waiting 0.5
|
|
93
|
+
# seconds before the second check and 1.5 times longer each time after that, up to 5 seconds.
|
|
94
|
+
# Webhooks report decisions without polling.
|
|
95
|
+
#
|
|
96
|
+
# `timeout:` is the whole wait in seconds; `request_options: {timeout:}` still limits each request.
|
|
97
|
+
# To stop waiting early, run the call in a thread you can stop: it only sleeps and sends requests.
|
|
98
|
+
# @option options [String] :asset_id
|
|
99
|
+
# @option options [Numeric] :timeout seconds to wait for a decision (default 60)
|
|
100
|
+
# @return [Hash] the decided asset
|
|
101
|
+
# @raise [TimeoutError] `sdk/timeout` when no decision arrives in time; `details` holds
|
|
102
|
+
# `'assetId'` and the last `'processingStatus'` and `'moderationStatus'`.
|
|
103
|
+
def wait_for_decision(options)
|
|
104
|
+
call(options) do |api, request|
|
|
105
|
+
timeout = api.fetch('timeout', DEFAULT_DECISION_TIMEOUT)
|
|
106
|
+
unless timeout.is_a?(Numeric) && timeout.real? && timeout.to_f.finite? && !timeout.negative?
|
|
107
|
+
raise Internal.invalid('timeout must be a non-negative number of seconds')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
poll_for_decision(api['assetId'], request, timeout)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Updates an asset's name, description, tags, visibility, metadata and similar fields.
|
|
115
|
+
# @option options [String] :asset_id
|
|
116
|
+
# @return [Hash] the updated asset
|
|
117
|
+
def update(options)
|
|
118
|
+
call(options) do |api, request|
|
|
119
|
+
request('PATCH', "/assets/#{Internal.segment(api['assetId'])}", body: without(api, 'assetId'),
|
|
120
|
+
request_options: request)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Processes an asset whose `processing.status` is `failed` again.
|
|
125
|
+
# @option options [String] :asset_id
|
|
126
|
+
# @return [Hash] the asset
|
|
127
|
+
def retry(options)
|
|
128
|
+
call(options) do |api, request|
|
|
129
|
+
request('POST', "/assets/#{Internal.segment(api['assetId'])}/retry", request_options: request)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Removes the stored content and returns the asset with `content.status` `removed` (reason
|
|
134
|
+
# `requested`); the record and moderation report are kept.
|
|
135
|
+
# @option options [String] :asset_id
|
|
136
|
+
# @return [Hash] the asset
|
|
137
|
+
def delete_content(options)
|
|
138
|
+
call(options) do |api, request|
|
|
139
|
+
request('DELETE', "/assets/#{Internal.segment(api['assetId'])}/content", request_options: request)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Deletes the asset record and its stored content.
|
|
144
|
+
# @option options [String] :asset_id
|
|
145
|
+
# @return [nil]
|
|
146
|
+
def delete(options)
|
|
147
|
+
call(options) do |api, request|
|
|
148
|
+
request('DELETE', "/assets/#{Internal.segment(api['assetId'])}", request_options: request)
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Moves an asset into a folder, or to the project root with `folder_id: nil`.
|
|
154
|
+
# @option options [String] :asset_id
|
|
155
|
+
# @option options [String, nil] :folder_id
|
|
156
|
+
# @return [Hash] the asset
|
|
157
|
+
def move(options)
|
|
158
|
+
call(options) do |api, request|
|
|
159
|
+
request('POST', "/assets/#{Internal.segment(api['assetId'])}/move",
|
|
160
|
+
body: { 'folderId' => Internal.required(api, 'folderId') }, request_options: request)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Creates a time-limited URL that delivers private content without an API key.
|
|
165
|
+
# @option options [String] :asset_id
|
|
166
|
+
# @option options [String] :resource `content` (default) or `preview`
|
|
167
|
+
# @option options [Integer] :expires_in_seconds default 900
|
|
168
|
+
# @option options [String] :lens a lens identifier
|
|
169
|
+
# @option options [String, Hash, Array<Hash>] :transform a selector String or structured steps,
|
|
170
|
+
# as for {CDN#get_asset_url}; a lens runs before any additional transforms
|
|
171
|
+
# @return [Hash] `{'url', 'expiresAt'}`
|
|
172
|
+
def create_signed_url(options)
|
|
173
|
+
call(options) do |api, request|
|
|
174
|
+
body = without(api, 'assetId')
|
|
175
|
+
body['transform'] = Transforms.selector(body['transform']) if body.key?('transform')
|
|
176
|
+
request('POST', "/assets/#{Internal.segment(api['assetId'])}/signed-url",
|
|
177
|
+
body: { 'resource' => 'content', 'expiresInSeconds' => 900 }.merge(body),
|
|
178
|
+
request_options: request)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Uploads content and queues moderation; the call returns before classification finishes.
|
|
183
|
+
# Watch for `asset.approved` and `asset.rejected` events or retrieve the asset later.
|
|
184
|
+
#
|
|
185
|
+
# Give exactly one of `content:` (a whole file), `part:` (the first part of a multipart upload) or
|
|
186
|
+
# `url:` (a web page or file to fetch). Content is `{type:, plain_text:}`, `{type:, base64:}` or
|
|
187
|
+
# `{type:, data:}` where `data` is a binary String or an IO. An IO streams without being read into
|
|
188
|
+
# memory; pass `size_in_bytes:` when its length is unknown (a pipe or socket).
|
|
189
|
+
#
|
|
190
|
+
# The request carries an `Idempotency-Key` (generated unless you pass
|
|
191
|
+
# `request_options: {idempotency_key:}`), so a retried upload never creates a second asset.
|
|
192
|
+
# @option options [String] :name
|
|
193
|
+
# @option options [Hash] :content
|
|
194
|
+
# @option options [Hash] :part `{type:, data:, is_final:}` for the first multipart part
|
|
195
|
+
# @option options [Integer] :total_size_in_bytes the whole file's size, for multipart uploads
|
|
196
|
+
# @option options [String] :url
|
|
197
|
+
# @return [Hash] the asset
|
|
198
|
+
def create(options)
|
|
199
|
+
call(options) { |api, request| submit(api, request, true) }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Uploads content and waits for the moderation decision where the content type supports it.
|
|
203
|
+
# Audio and video are always processed asynchronously. Takes the options of {#create}.
|
|
204
|
+
# @return [Hash] the asset; publish only when `moderation.status` is `approved` and
|
|
205
|
+
# `moderation.action` is nil.
|
|
206
|
+
def moderate(options)
|
|
207
|
+
call(options) do |api, request|
|
|
208
|
+
content = api['content']
|
|
209
|
+
if content.is_a?(Hash) && content.key?('plainText') && (!content['plainText'].is_a?(String) || content['plainText'].strip.empty?)
|
|
210
|
+
raise Internal.invalid('Text to moderate must be a non-empty string', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
submit(api, request, false)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Uploads a later part of a multipart upload started with {#create} or {#moderate} and `part:`.
|
|
218
|
+
# The queue mode chosen at the start applies; pass `queue:` only to override it.
|
|
219
|
+
# @option options [String] :asset_id
|
|
220
|
+
# @option options [Hash] :part `{data:, part_number:, is_final:}`; part numbers start at 2.
|
|
221
|
+
# @option options [Boolean] :queue
|
|
222
|
+
# @return [Hash] the asset; `processing.status` stays `uploading` until the final part.
|
|
223
|
+
def upload_part(options)
|
|
224
|
+
call(options) do |api, request|
|
|
225
|
+
path = "/assets/#{Internal.segment(api['assetId'])}/part"
|
|
226
|
+
part = api['part']
|
|
227
|
+
raise Internal.invalid('part is required') unless part.is_a?(Hash)
|
|
228
|
+
|
|
229
|
+
number = part['partNumber']
|
|
230
|
+
unless number.is_a?(Integer) && number.positive?
|
|
231
|
+
raise Internal.invalid('partNumber must be a positive Integer',
|
|
232
|
+
ErrorCodes::INVALID_UPLOAD_PART_NUMBER)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
body = UploadBody.from(part, multipart: true)
|
|
236
|
+
query = { 'partNumber' => number, 'isFinal' => part.fetch('isFinal', false), 'sizeInBytes' => body.size }
|
|
237
|
+
query['queue'] = api['queue'] if api.key?('queue')
|
|
238
|
+
request('POST', path, query: query, raw: body, idempotent: true, request_options: request,
|
|
239
|
+
headers: { 'Content-Type' => 'application/octet-stream', 'X-CalmLens-Upload' => 'part' })
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private
|
|
244
|
+
|
|
245
|
+
def poll_for_decision(asset_id, request_options, timeout)
|
|
246
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
247
|
+
delay = DECISION_POLL_FIRST_DELAY
|
|
248
|
+
loop do
|
|
249
|
+
asset = retrieve('assetId' => asset_id, 'request_options' => request_options)
|
|
250
|
+
processing = asset.dig('processing', 'status')
|
|
251
|
+
moderation = asset.dig('moderation', 'status')
|
|
252
|
+
return asset if DECIDED_MODERATION_STATUSES.include?(moderation) || FINISHED_PROCESSING_STATUSES.include?(processing)
|
|
253
|
+
|
|
254
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
255
|
+
unless remaining.positive?
|
|
256
|
+
raise TimeoutError.new(SdkErrorCodes::TIMEOUT,
|
|
257
|
+
"Asset #{asset['id']} had no moderation decision after #{timeout}s " \
|
|
258
|
+
"(processing #{processing}, moderation #{moderation})",
|
|
259
|
+
details: { 'assetId' => asset['id'], 'processingStatus' => processing,
|
|
260
|
+
'moderationStatus' => moderation })
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
sleep([delay, remaining].min)
|
|
264
|
+
delay = [delay * 1.5, DECISION_POLL_MAX_DELAY].min
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def submit(options, request_options, queue)
|
|
269
|
+
raise Internal.invalid('Asset name is required', ErrorCodes::INVALID_UPLOAD_PAYLOAD) if options['name'].to_s.empty?
|
|
270
|
+
unless %w[content part url].one? { |key| options.key?(key) } && !options.key?('isFinal')
|
|
271
|
+
raise Internal.invalid('Supply exactly one of content, part, or url; isFinal belongs in part',
|
|
272
|
+
ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
273
|
+
end
|
|
274
|
+
if options.key?('url')
|
|
275
|
+
return request('POST', project('assets'), query: { 'queue' => queue }, body: options, idempotent: true,
|
|
276
|
+
request_options: request_options)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
multipart = options.key?('part')
|
|
280
|
+
content = options.fetch(multipart ? 'part' : 'content')
|
|
281
|
+
raise Internal.invalid('Upload content must be a Hash', ErrorCodes::INVALID_UPLOAD_PAYLOAD) unless content.is_a?(Hash)
|
|
282
|
+
|
|
283
|
+
mime = check_mime(content['type'])
|
|
284
|
+
if multipart && content.fetch('partNumber', 1) != 1
|
|
285
|
+
raise Internal.invalid('Use uploadPart for multipart upload parts after the first part', ErrorCodes::USE_UPLOAD_PART)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
body = UploadBody.from(content, multipart: multipart)
|
|
289
|
+
query = options.slice(*SUBMIT_QUERY_KEYS).merge('queue' => queue, 'contentType' => content['type'],
|
|
290
|
+
'sizeInBytes' => body.size)
|
|
291
|
+
if multipart
|
|
292
|
+
total = options['totalSizeInBytes']
|
|
293
|
+
unless total.nil? || (total.is_a?(Integer) && total.positive?)
|
|
294
|
+
raise Internal.invalid('totalSizeInBytes must be a positive Integer', ErrorCodes::INVALID_UPLOAD_PAYLOAD)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
check_image_size(total || body.size, total || content.fetch('isFinal', false)) if mime.start_with?('image/')
|
|
298
|
+
query.merge!('partNumber' => 1, 'isFinal' => content.fetch('isFinal', false), 'totalSizeInBytes' => total)
|
|
299
|
+
end
|
|
300
|
+
request('POST', project('assets'),
|
|
301
|
+
query: query, raw: body, idempotent: true, request_options: request_options,
|
|
302
|
+
headers: { 'Content-Type' => content['type'], 'X-CalmLens-Upload' => multipart ? 'part' : 'content' })
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def check_mime(type)
|
|
306
|
+
mime = type.to_s.split(';').first.to_s.strip.downcase
|
|
307
|
+
if mime.empty? || mime == 'application/octet-stream' || mime.start_with?('multipart/')
|
|
308
|
+
raise Internal.invalid("#{mime} is not a valid upload file type. Uploads must use a concrete file MIME type " \
|
|
309
|
+
'that CalmLens can store and process, such as image/png, image/jpeg, ' \
|
|
310
|
+
'application/pdf, or text/plain.', ErrorCodes::INVALID_UPLOAD_MIME_TYPE)
|
|
311
|
+
end
|
|
312
|
+
raise Internal.invalid('Content type cannot contain line breaks') unless type.match?(Internal::HEADER_VALUE)
|
|
313
|
+
|
|
314
|
+
mime
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Multipart images total 10MB to 20MB. With a declared total both bounds apply from the start.
|
|
318
|
+
def check_image_size(size, complete)
|
|
319
|
+
if size > MAX_MULTIPART_IMAGE_BYTES
|
|
320
|
+
raise Internal.invalid('Multipart image uploads cannot exceed 20MB.',
|
|
321
|
+
ErrorCodes::MULTIPART_IMAGE_TOO_LARGE)
|
|
322
|
+
end
|
|
323
|
+
return unless complete && size < MIN_MULTIPART_IMAGE_BYTES
|
|
324
|
+
|
|
325
|
+
raise Internal.invalid('Multipart image uploads must total at least 10MB.', ErrorCodes::MULTIPART_IMAGE_TOO_SMALL)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
data/lib/calmlens/cdn.rb
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CalmLens
|
|
4
|
+
# CDN URLs and authenticated content delivery. Reach it through {ServerClient#cdn}.
|
|
5
|
+
class CDN < Resource
|
|
6
|
+
# Builds the CDN URL of an asset's content or preview. Private assets need the API key
|
|
7
|
+
# ({#serve}) or a signed URL ({Assets#create_signed_url}).
|
|
8
|
+
# @option options [String] :asset_identifier the asset ID or external ID
|
|
9
|
+
# @option options [String] :resource `content` (default) or `preview`
|
|
10
|
+
# @option options [String] :lens a lens identifier
|
|
11
|
+
# @option options [String, Hash, Array<Hash>] :transform a selector such as `imageTransform[width=200]`,
|
|
12
|
+
# or structured steps: `{type: 'imageTransform', options: {width: 200}}` or an Array of them
|
|
13
|
+
# @return [String]
|
|
14
|
+
def get_asset_url(options)
|
|
15
|
+
call(options) { |api, _request| url(api) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Fetches content from the CDN with the client's API key.
|
|
19
|
+
#
|
|
20
|
+
# With a block, yields a {Response} whose body streams: call {Response#each_chunk} to write
|
|
21
|
+
# it somewhere without holding it in memory. The method returns the block's value. Without a
|
|
22
|
+
# block, returns the {Response} with its body read into memory. Error statuses are returned,
|
|
23
|
+
# not raised; check {Response#status}.
|
|
24
|
+
#
|
|
25
|
+
# @option options [String] :asset_identifier
|
|
26
|
+
# @option options [Hash] :init `{method: 'GET' | 'HEAD', headers: {...}}`
|
|
27
|
+
# @yieldparam response [Response]
|
|
28
|
+
# @return [Response, Object]
|
|
29
|
+
# @example Save content to a file
|
|
30
|
+
# File.open('photo.jpg', 'wb') do |file|
|
|
31
|
+
# client.cdn.serve(asset_identifier: id) do |response|
|
|
32
|
+
# raise "CDN returned #{response.status}" unless response.success?
|
|
33
|
+
# response.each_chunk { |chunk| file.write(chunk) }
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
def serve(options, &)
|
|
37
|
+
method, url, headers, request = call(options) do |api, request_options|
|
|
38
|
+
init = api.fetch('init', {})
|
|
39
|
+
raise Internal.invalid('init must be a Hash') unless init.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
verb = init.fetch('method', 'GET').to_s.upcase
|
|
42
|
+
raise Internal.invalid('init.method must be GET or HEAD') unless %w[GET HEAD].include?(verb)
|
|
43
|
+
|
|
44
|
+
[verb, url(api), Internal.headers(init.fetch('headers', {})), request_options]
|
|
45
|
+
end
|
|
46
|
+
@session.cdn(method, url, headers, request, &)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def url(api)
|
|
52
|
+
resource = api.fetch('resource', 'content')
|
|
53
|
+
raise Internal.invalid('Resource must be content or preview') unless %w[content preview].include?(resource)
|
|
54
|
+
|
|
55
|
+
url = "#{@session.cdn_base_url}/assets/#{Internal.segment(@session.project_id)}/" \
|
|
56
|
+
"#{Internal.segment(api['assetIdentifier'])}/#{resource}"
|
|
57
|
+
url += "/@#{Internal.segment(api['lens'])}" if api['lens']
|
|
58
|
+
transform = api['transform']
|
|
59
|
+
url += "#{api['lens'] ? ',' : '/'}#{Transforms.path(transform)}" unless transform.nil? || transform == ''
|
|
60
|
+
url
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|