calmlens 0.17.1 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +53 -266
- data/lib/calmlens/cdn.rb +8 -2
- data/lib/calmlens/version.rb +1 -1
- data/sig/types.rbs +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ad685bf3d43987138816bb9986a25b8b190d3f462ce333722a37a24e96b3ee9b
|
|
4
|
+
data.tar.gz: 59ee3d29e0a2cc8fb9ccc0623d4086016e10bffd8b3cc384cd4058a6b83419d9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40b3f300a7cb32e057230c11e1e217e2484d025d02a0b0de0635e2fe2c8bbcee563d9fddb7bb33c87bbbc0b3a834a5f73017e13e18ecb140213b332ab55a99a9
|
|
7
|
+
data.tar.gz: 46fa885d37fea9c043b01fce0011a92fee448e1d45ff510cbad67da600cf4bef409f538454654c0083f4173bac9b38d4cadf75ecc1ec3f166a44e066772b341f
|
data/README.md
CHANGED
|
@@ -1,299 +1,86 @@
|
|
|
1
1
|
# CalmLens Ruby SDK
|
|
2
2
|
|
|
3
|
-
Ruby 3.1+ server-side client for CalmLens moderation, uploads, events, webhooks and CDN delivery.
|
|
3
|
+
Ruby 3.1+ server-side client for [CalmLens](https://calmlens.com) content moderation, uploads, events, webhooks and CDN delivery.
|
|
4
4
|
|
|
5
|
-
|
|
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.classify(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/classify/search/list/each/retrieve/wait_for_decision/update/retry/decide/explain/delete_content/delete/batch/move/list_children/each_child/upload_part/create_signed_url; folders list/create/retrieve/update/move/delete; hash lists list/retrieve/create/update/delete/list_entries/add_entry/remove_entry; 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
|
-
| `upload_timeout` | Seconds allowed for each attempt of a binary upload for this call. |
|
|
29
|
-
| `max_retries` | Retries for this call (see below). |
|
|
30
|
-
| `max_response_bytes` | The largest response body this call reads. |
|
|
31
|
-
|
|
32
|
-
## Types
|
|
33
|
-
|
|
34
|
-
`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_classification`, `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` and the rest. Steep and other RBS tools check your calls and the response keys you read:
|
|
35
|
-
|
|
36
|
-
```ruby
|
|
37
|
-
asset = client.assets.classify(name: 'photo.jpg', content: {data: File.binread('photo.jpg'), type: 'image/jpeg'},
|
|
38
|
-
processing_options: {features: {face_detection: {enabled: true}}})
|
|
39
|
-
asset['classification']['moderation']['status'] # CalmLens::asset_moderation_status
|
|
40
|
-
asset.dig('classification', 'faceDetection', 'faces') # Array[CalmLens::detected_face]?
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
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`).
|
|
44
|
-
|
|
45
|
-
## Classification results
|
|
46
|
-
|
|
47
|
-
`assets.classify` and `text.classify` return the asset, whose state is in three parts:
|
|
48
|
-
|
|
49
|
-
- `upload`: receipt of source content, with `status` (`uploading` or `completed`), `updatedAt` and `partsReceived` for multipart uploads.
|
|
50
|
-
- `classification`: `status` (`pending`, `processing`, `completed` or `failed`), `failureReason` (why processing failed, while `status` is `failed`; otherwise `null`: `file-too-large`, `duration-limit`, `resolution-limit`, `unsupported-format`, `undecodable-content`, `multiple-tracks`, `type-mismatch`, `free-upload-limit`, `child-failed` or `processing-error`), `updatedAt`, `completedAt`, optional `labels`, and `moderation`. The moderation section contains `status` (`pending`, `allowed`, `limited`, `age-restricted`, `blocked` or `purged`), policy-adjusted `categories`, `automatedAction`, `flaggedCategories`, `explanationComplete`, and `raw` findings with an optional `timeline` and `manualReviews`, each with the reviewer's `decision`. A reviewer decision does not change `completedAt`.
|
|
51
|
-
- `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` (the picture served at the rendered resource), `extractedText`, `fileMetadata` (metadata embedded in an uploaded image, such as its capture time, camera and software) and `safetyHold`.
|
|
52
|
-
|
|
53
|
-
The moderation status also decides delivery. Show content to everyone only when `asset['classification']['moderation']['status']` is `allowed` or `limited`. CalmLens delivers a `limited` asset like an `allowed` one; it is marked so your app can reduce its reach, such as keeping it out of recommendations, trending and notifications, ranking it lower or collapsing it. CalmLens delivers an `age-restricted` asset only with `viewerClaims.ageConfirmed=true`, so show it only behind your own age check. Don't show `blocked` assets (content kept, not delivered) or `purged` ones (content deleted, `removalReason` `policy`). A reviewer's decision takes precedence over the policy's; allowing a purged asset does not restore removed content. 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.
|
|
54
|
-
|
|
55
|
-
### Waiting for a decision
|
|
56
|
-
|
|
57
|
-
`assets.wait_for_decision` waits for a queued asset's decision and returns the asset once `classification.moderation.status` is no longer `pending`, or `classification.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 `'classificationStatus'` and `'moderationStatus'`. Use it after `assets.create`, or with an asset ID you stored earlier. Webhooks report decisions without polling.
|
|
58
|
-
|
|
59
|
-
```ruby
|
|
60
|
-
queued = client.assets.create(name: 'photo.jpg', content: {data: File.open('photo.jpg', 'rb'), type: 'image/jpeg'})
|
|
61
|
-
decided = client.assets.wait_for_decision(asset_id: queued['id'], timeout: 30)
|
|
62
|
-
show_photo(decided['id']) if %w[allowed limited].include?(decided['classification']['moderation']['status'])
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
`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.
|
|
66
|
-
|
|
67
|
-
### Recording a reviewer's decision
|
|
68
|
-
|
|
69
|
-
`assets.decide` replaces the policy's decision with a reviewer's: `classification.moderation.status` becomes the decision, the decision and note join `manualReviews`, and the matching `asset.<status>` event is sent with `decided-by: reviewer`. An asset still pending moderation can only be blocked or purged. Purging needs `confirm_purge: true` and the `assets:delete` permission, and permanently deletes the stored content.
|
|
5
|
+
## Installation
|
|
70
6
|
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
client.assets.decide(asset_id: asset_id, decision: 'purged', note: 'Illegal content', confirm_purge: true)
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
### Replies in a conversation
|
|
77
|
-
|
|
78
|
-
`assets.create`, `assets.classify` and `text.classify` take `author_id:`, your identifier for who wrote the content, and `context:`, the earlier posts in the conversation, oldest first. CalmLens moderates only the content itself and uses the earlier posts to read who it addresses and what it refers to; it compares author IDs only with each other, to tell which posts share a speaker.
|
|
79
|
-
|
|
80
|
-
```ruby
|
|
81
|
-
client.text.classify(text: 'Same to you', author_id: 'user-9',
|
|
82
|
-
context: {items: [{text: 'Nice photo!', author_id: 'user-1'}, {text: 'Thanks', author_id: 'user-9'}]})
|
|
7
|
+
```sh
|
|
8
|
+
gem install calmlens
|
|
83
9
|
```
|
|
84
10
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
## Uploads and delivery
|
|
88
|
-
|
|
89
|
-
Content is `{type:, plain_text:}`, `{type:, base64:}` or `{type:, data:}`, where `data` is a binary String or an IO. An IO is streamed and never read into memory: a File, Tempfile or StringIO (or any IO with `size_in_bytes:`) is sent with a `Content-Length`, and a stream of unknown length, such as a pipe or socket, is sent with chunked transfer encoding and no `sizeInBytes`. A stream that ends early or holds more bytes than declared fails with `upload/invalid-payload`.
|
|
11
|
+
Or add it to your Gemfile:
|
|
90
12
|
|
|
91
13
|
```ruby
|
|
92
|
-
|
|
93
|
-
client.assets.create(name: 'video.mp4', content: {data: file, type: 'video/mp4'})
|
|
94
|
-
end
|
|
14
|
+
gem "calmlens"
|
|
95
15
|
```
|
|
96
16
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
`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.
|
|
100
|
-
|
|
101
|
-
```ruby
|
|
102
|
-
File.open('photo.jpg', 'wb') do |file|
|
|
103
|
-
client.cdn.serve(asset_identifier: asset_id) do |response|
|
|
104
|
-
raise "CDN returned #{response.status}" unless response.success?
|
|
105
|
-
response.each_chunk { |chunk| file.write(chunk) }
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
```
|
|
17
|
+
The gem has no runtime dependencies: it uses only the standard library (Net::HTTP, JSON, OpenSSL). It ships YARD documentation and RBS signatures in `sig/`, so Steep and other RBS tools can check your calls and the response keys you read.
|
|
109
18
|
|
|
110
|
-
|
|
19
|
+
## Quick start
|
|
111
20
|
|
|
112
|
-
|
|
21
|
+
Create an API key and copy your project ID in the CalmLens portal, and keep both on your server.
|
|
113
22
|
|
|
114
23
|
```ruby
|
|
115
|
-
|
|
116
|
-
client.cdn.get_asset_url(asset_identifier: asset_id, transform: {type: 'imageTransform', options: {width: 1024, format: 'png'}})
|
|
117
|
-
# => ".../rendered/format=png,width=1024" in both cases
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
A selector has one path segment per step, such as `fit=cover,width=640/format=webp`, and a lens and a transform are joined with `/` (`/rendered/@thumbnail/width=320`). Nested options are dotted (`border.color`) and hex colours drop the `#`.
|
|
121
|
-
|
|
122
|
-
A public asset whose moderation status is `age-restricted` is served from its public URL only with a JSON `viewerClaims` query parameter containing `{ "ageConfirmed": true }`; otherwise delivery is refused, even with a signed URL or API key. Pass `viewer_claims: { age_confirmed: true }` to `cdn.get_asset_url` (or `cdn.serve`) to add it after any lens or transform. Set it only where your app has already decided the viewer may see age-restricted content.
|
|
123
|
-
|
|
124
|
-
## Upload presets
|
|
125
|
-
|
|
126
|
-
Pass `upload_preset_id:` to `assets.create` or `assets.classify` 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.
|
|
127
|
-
|
|
128
|
-
```ruby
|
|
129
|
-
signed = client.upload_tokens.sign(
|
|
130
|
-
upload_preset_id: 'preset_4Lk8Qw2Xm9Rt5Vb3Nc7P',
|
|
131
|
-
external_id: 'post-42', # optional: the uploaded asset gets this ID
|
|
132
|
-
expires_in_seconds: 600 # optional: 1 to 3600, 600 by default
|
|
133
|
-
)
|
|
134
|
-
# signed['token'], signed['expiresAt'] (milliseconds since the Unix epoch)
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
See [Upload presets](https://calmlens.com/docs/guides/upload-presets). `CalmLens::MockServerClient` signs tokens with its own key.
|
|
138
|
-
|
|
139
|
-
## Pagination
|
|
140
|
-
|
|
141
|
-
Every list returns a cursor page: pass its `nextCursor` as `cursor:` to fetch the next one (`page_size:` sets its length, 1 to 100, default 50). `ordering:` entries take a `field:` and a lowercase `direction:`, `'asc'` or `'desc'`. Folder pages also hold the `ancestors` of the listed folders, and `folders.list` takes one `ordering:` entry by `name` (the default, ascending) or `createdAt`.
|
|
142
|
-
|
|
143
|
-
```ruby
|
|
144
|
-
page = client.folders.list(parent_id: folder_id, page_size: 50) # items, ancestors, nextCursor, hasMore
|
|
145
|
-
page = client.folders.list(parent_id: folder_id, page_size: 50, cursor: page['nextCursor']) if page['hasMore']
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
`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: 'classification.moderation.status==allowed').lazy.first(5)` stops after the pages it needs.
|
|
149
|
-
|
|
150
|
-
## Hash lists
|
|
151
|
-
|
|
152
|
-
Image uploads are compared with every hash list in the project: a `flag` list reports matches, and a `block` list also blocks close matches. Add a 64-character PDQ hash, or an image asset whose hash CalmLens computed; adding a hash already in the list returns its existing entry.
|
|
153
|
-
|
|
154
|
-
```ruby
|
|
155
|
-
list = client.hash_lists.create(name: 'Removed images', action: 'block')
|
|
156
|
-
entry = client.hash_lists.add_entry(hash_list_id: list['id'], asset_id: asset_id, label: 'Report 1042')
|
|
157
|
-
page = client.hash_lists.list_entries(hash_list_id: list['id'], page_size: 50) # items, nextCursor, hasMore
|
|
158
|
-
client.hash_lists.update(hash_list_id: list['id'], action: 'flag', description: nil)
|
|
159
|
-
client.hash_lists.remove_entry(entry_id: entry['id'])
|
|
160
|
-
client.hash_lists.delete(hash_list_id: list['id']) # deletes its entries too
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
An asset without a perceptual hash fails with `hash-list/asset-not-hashed`.
|
|
24
|
+
require 'calmlens'
|
|
164
25
|
|
|
165
|
-
|
|
26
|
+
client = CalmLens::ServerClient.new(api_key: ENV.fetch('CALMLENS_API_KEY'), project_id: ENV.fetch('CALMLENS_PROJECT_ID'))
|
|
166
27
|
|
|
167
|
-
|
|
28
|
+
# Classify text and wait for the decision.
|
|
29
|
+
asset = client.text.classify(text: 'Great photo!', external_id: 'comment-42')
|
|
168
30
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
client.events.each_page(cursor: cursor, page_size: 50) do |page|
|
|
173
|
-
page['items'].each { |event| handle(event) } # event['type'], event['facts'], event['assetId']
|
|
174
|
-
cursor = page['nextCursor'] # persist it
|
|
31
|
+
# Upload a file and queue processing, then wait for its decision.
|
|
32
|
+
queued = File.open('photo.jpg', 'rb') do |file|
|
|
33
|
+
client.assets.create(name: 'photo.jpg', content: {data: file, type: 'image/jpeg'})
|
|
175
34
|
end
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
Each event has `id`, `projectId`, `type`, `severity`, `title`, `description`, `assetId`, `asset` (the asset's status when the event was created), `facts` and `createdAt`; `description`, `assetId` and `asset` are nil when the event has none. `facts` is an array of `{'key', 'value', 'url'?}` entries registered for its `type`. Webhook deliveries carry the same events in the same shape. An unknown cursor raises `validation/invalid-cursor`.
|
|
179
|
-
|
|
180
|
-
## Webhooks
|
|
181
|
-
|
|
182
|
-
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` with the fields of an `events.list` item: `id`, `project_id`, `type`, `severity`, `title`, `description`, `asset_id`, `asset`, `facts` and `created_at`, plus `test?`. `description`, `asset_id` and `asset` are nil when the event has none. Fields CalmLens adds later are kept: `to_payload` returns the whole body as received, including unknown fields, asset fields and fact fields. `CalmLens.to_webhook_event_payload(event)` turns an `events.list` item into the same body. Instead of `signature:` and `timestamp:`, you can pass the request's `headers:`: a Hash with names in any case (repeated values as an Array are joined with `,`), `Net::HTTPHeader`, or a Rack env. 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`.
|
|
183
|
-
|
|
184
|
-
Rails:
|
|
35
|
+
decided = client.assets.wait_for_decision(asset_id: queued['id'], timeout: 30)
|
|
185
36
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def create
|
|
189
|
-
event = CalmLens.parse_webhook_event(
|
|
190
|
-
payload: request.raw_post,
|
|
191
|
-
signature: request.headers[CalmLens::WEBHOOK_SIGNATURE_HEADER],
|
|
192
|
-
timestamp: request.headers[CalmLens::WEBHOOK_TIMESTAMP_HEADER],
|
|
193
|
-
secret: ENV.fetch('CALMLENS_WEBHOOK_SECRET')
|
|
194
|
-
)
|
|
195
|
-
# Acknowledge first; do the work in a job. Skip a delivery ID you have already queued.
|
|
196
|
-
CalmLensEventJob.perform_later(request.headers[CalmLens::WEBHOOK_DELIVERY_HEADER], event.to_payload)
|
|
197
|
-
head :no_content
|
|
198
|
-
rescue CalmLens::WebhookVerificationError
|
|
199
|
-
head :bad_request
|
|
200
|
-
end
|
|
37
|
+
if %w[allowed limited].include?(decided['classification']['moderation']['status'])
|
|
38
|
+
puts "Show #{decided['id']}"
|
|
201
39
|
end
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
Sinatra (or any Rack app):
|
|
205
40
|
|
|
206
|
-
|
|
207
|
-
post '/webhooks/calmlens' do
|
|
208
|
-
request.body.rewind
|
|
209
|
-
event = CalmLens.parse_webhook_event(
|
|
210
|
-
payload: request.body.read,
|
|
211
|
-
signature: request.env['HTTP_X_CALMLENS_SIGNATURE'],
|
|
212
|
-
timestamp: request.env['HTTP_X_CALMLENS_TIMESTAMP'],
|
|
213
|
-
secret: ENV.fetch('CALMLENS_WEBHOOK_SECRET')
|
|
214
|
-
)
|
|
215
|
-
enqueue(request.env['HTTP_X_CALMLENS_DELIVERY'], event)
|
|
216
|
-
204
|
|
217
|
-
rescue CalmLens::WebhookVerificationError
|
|
218
|
-
400
|
|
219
|
-
end
|
|
41
|
+
client.close
|
|
220
42
|
```
|
|
221
43
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
## Errors
|
|
225
|
-
|
|
226
|
-
Every error the SDK raises is a `CalmLens::Error` with `code`, `message`, `details` (the API's details object, such as validation `issues`, or nil), `status` (nil without a response), `request_id` (the `X-Request-Id` header, or the error body's `requestId`; include it when you contact support), `headers`, `retry_after` (whole seconds from `Retry-After`) and the original exception in `cause`. Helpers: `not_found?` and `retryable?` (true for `sdk/transport-error`, `sdk/timeout`, `idempotency/request-in-progress`, `429`, `502`, `503` and `504`, whichever error class carries them). 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`.
|
|
227
|
-
|
|
228
|
-
| Class | When |
|
|
229
|
-
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
230
|
-
| `CalmLens::APIError` and its subclasses `BadRequestError`, `AuthenticationError`, `PermissionDeniedError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `ServerError` | The API answered with an error status, or with a redirect (`sdk/http-error` with the 3xx status), which is never followed. |
|
|
231
|
-
| `CalmLens::ConnectionError` (`sdk/transport-error`), `CalmLens::TimeoutError` (`sdk/timeout`) | No complete response arrived, or `assets.wait_for_decision` had no decision in time. |
|
|
232
|
-
| `CalmLens::InvalidResponseError` (`sdk/invalid-response`, `sdk/response-too-large`) | A successful response could not be read or was not what the API returns (such as an upload answered without an asset), or a response was larger than `max_response_bytes:` (default 32 MiB). |
|
|
233
|
-
| `CalmLens::InvalidArgumentError` (`sdk/invalid-argument`, or an `upload/*` code for payload checks the API also makes) | An argument was rejected before sending. |
|
|
234
|
-
| `CalmLens::ConfigurationError` (`sdk/invalid-config`) | `ServerClient.new` received an invalid setting, such as a malformed `base_url`. |
|
|
235
|
-
| `CalmLens::WebhookVerificationError` | A webhook delivery failed verification. |
|
|
236
|
-
|
|
237
|
-
## Retries and timeouts
|
|
44
|
+
Options are keyword arguments in snake_case (camelCase also works, at any depth). Responses are Hashes with the API's camelCase String keys.
|
|
238
45
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
`timeout:` (default 120 seconds) limits each socket read or write, not a whole transfer, so large downloads are not cut off. `open_timeout:` (default 10 seconds) limits connecting. Binary uploads (file content and multipart parts) have no deadline unless you set `upload_timeout:`, which then limits each attempt as a whole; text content follows `timeout:`. `max_response_bytes:` (default 32 MiB) caps the response body read: a larger one fails with `sdk/response-too-large`, before it is read when its `Content-Length` says so. All of them can be set per call with `request_options:`.
|
|
242
|
-
|
|
243
|
-
## Connections
|
|
244
|
-
|
|
245
|
-
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.
|
|
246
|
-
|
|
247
|
-
## Offline testing
|
|
248
|
-
|
|
249
|
-
```ruby
|
|
250
|
-
require 'calmlens/testing'
|
|
251
|
-
key = CalmLens::MockServerClient.create_api_key([{actions: ['assets:download'], folder_id: nil, include_subfolders: true}])
|
|
252
|
-
moderate = lambda do |input| # decides each upload, retry and rerun
|
|
253
|
-
next 'pending' if input.queued && input.trigger == 'upload' # finish queued uploads with complete_processing
|
|
254
|
-
next 'allowed' unless input.content.text&.include?('kill')
|
|
255
|
-
|
|
256
|
-
CalmLens::MockDecision.of('blocked').flag('violence', 0.92, 'violence-threat' => 0.9)
|
|
257
|
-
end
|
|
258
|
-
mock = CalmLens::MockServerClient.new(api_key: key, moderate: moderate) # the default key cannot read private CDN content
|
|
259
|
-
asset = mock.assets.classify(name: 'note', content: {plain_text: 'hello', type: 'text/plain'},
|
|
260
|
-
processing_options: {keep_content_after_processing: true})
|
|
261
|
-
mock.assets.retrieve(asset_id: asset['id']).dig('classification', 'moderation', 'status') # => "allowed"
|
|
262
|
-
mock.assets.list(filter: 'classification.moderation.status==allowed')['items'].length # => 1
|
|
263
|
-
mock.cdn.serve(asset_identifier: asset['id']).body # => "hello"
|
|
264
|
-
|
|
265
|
-
mock.enqueue(status: 503, body: {code: 'server/unavailable', message: 'Busy'}) # answers the next request
|
|
266
|
-
mock.enqueue_error(Net::ReadTimeout.new) # the next request raises CalmLens::TimeoutError
|
|
267
|
-
mock.enqueue_event(type: 'asset.allowed', asset_id: asset['id']) # joins the events feed
|
|
268
|
-
mock.decide(asset['id'], 'blocked') # a reviewer's decision, with its asset.blocked event
|
|
269
|
-
queued = mock.assets.create(name: 'later', content: {plain_text: 'hi', type: 'text/plain'}) # pending
|
|
270
|
-
mock.complete_processing(queued['id'], CalmLens::MockDecision.of('age-restricted').flag('sexual', 0.8))
|
|
271
|
-
mock.fail_next_request('upload/limit-exceeded', operation: 'assets.create') # the next create request fails with that API error
|
|
272
|
-
mock.fail_next_request('upload/website-rate-limit-exceeded', retry_after: 30, details: {'reason' => 'busy'})
|
|
273
|
-
mock.api_key_grants = [] # the same key, now without grants
|
|
274
|
-
CalmLens::MockServerClient.sign_upload_token(key, {upload_preset_id: 'preset_4Lk8Qw2Xm9Rt5Vb3Nc7P'}, now: 1_700_000_000_000, nonce: 'n')
|
|
275
|
-
|
|
276
|
-
request = mock.requests.last
|
|
277
|
-
request.method # => "GET"
|
|
278
|
-
request.path # => "/assets/project-1/<id>/rendered" (relative to base_url or cdn_base_url, segments decoded)
|
|
279
|
-
request.status # => 200 (nil when the request failed without a response)
|
|
280
|
-
mock.clear_requests
|
|
281
|
-
```
|
|
46
|
+
`asset['classification']['moderation']['status']` decides what to do with the content:
|
|
282
47
|
|
|
283
|
-
|
|
48
|
+
| Status | What to do |
|
|
49
|
+
| ---------------- | ------------------------------------------------------------------------------------ |
|
|
50
|
+
| `pending` | Not decided yet. Wait, poll or listen for a webhook. |
|
|
51
|
+
| `allowed` | Show it to everyone. |
|
|
52
|
+
| `limited` | Show it, but reduce its reach, such as keeping it out of recommendations. |
|
|
53
|
+
| `age-restricted` | Show it only behind your own age check, with `viewer_claims: {age_confirmed: true}`. |
|
|
54
|
+
| `blocked` | Don't show it. The content is kept but not delivered. |
|
|
55
|
+
| `purged` | Don't show it. The content was deleted. |
|
|
284
56
|
|
|
285
|
-
|
|
57
|
+
New statuses and fields may appear in later API versions. The SDK returns them unchanged, so treat a status you don't recognise as not ready.
|
|
286
58
|
|
|
287
|
-
|
|
59
|
+
## What you can do
|
|
288
60
|
|
|
289
|
-
|
|
61
|
+
- [Moderate text, images, documents, audio, video and websites](https://calmlens.com/docs/sdk/ruby#moderate-content), and [read classification results](https://calmlens.com/docs/guides/classification-results).
|
|
62
|
+
- [Wait for a queued decision](https://calmlens.com/docs/sdk/ruby#waiting-for-a-decision) or [receive it by webhook](https://calmlens.com/docs/guides/webhooks).
|
|
63
|
+
- [Stream uploads from any IO](https://calmlens.com/docs/sdk/ruby/reference#upload-content) and send [multipart uploads](https://calmlens.com/docs/guides/multipart-uploads).
|
|
64
|
+
- [Moderate replies with their conversation](https://calmlens.com/docs/sdk/ruby/reference#conversation-context).
|
|
65
|
+
- [Sign upload tokens for browser uploads](https://calmlens.com/docs/sdk/ruby/reference#upload-tokens) through [upload presets](https://calmlens.com/docs/guides/upload-presets).
|
|
66
|
+
- [Record reviewer decisions](https://calmlens.com/docs/sdk/ruby/reference#reviewer-decisions) and [explain a decision](https://calmlens.com/docs/sdk/ruby#methods).
|
|
67
|
+
- [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).
|
|
68
|
+
- [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).
|
|
69
|
+
- [Match images against hash lists](https://calmlens.com/docs/sdk/ruby/reference#hash-lists).
|
|
70
|
+
- [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).
|
|
71
|
+
- [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).
|
|
72
|
+
- [Type-check your code with RBS signatures](https://calmlens.com/docs/sdk/ruby/reference#types).
|
|
290
73
|
|
|
291
|
-
|
|
74
|
+
Every method is listed in the [Ruby SDK documentation](https://calmlens.com/docs/sdk/ruby#methods).
|
|
292
75
|
|
|
293
|
-
|
|
76
|
+
## Testing
|
|
294
77
|
|
|
295
|
-
|
|
78
|
+
`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).
|
|
296
79
|
|
|
297
|
-
|
|
80
|
+
## Links
|
|
298
81
|
|
|
299
|
-
|
|
82
|
+
- [Documentation](https://calmlens.com/docs/sdk/ruby)
|
|
83
|
+
- [SDK reference](https://calmlens.com/docs/sdk/ruby/reference)
|
|
84
|
+
- [API reference](https://calmlens.com/docs/api)
|
|
85
|
+
- [Changelog](https://github.com/CalmLens/sdks/blob/main/ruby/CHANGELOG.md)
|
|
86
|
+
- [License (MIT)](https://github.com/CalmLens/sdks/blob/main/ruby/LICENSE)
|
data/lib/calmlens/cdn.rb
CHANGED
|
@@ -64,7 +64,7 @@ module CalmLens
|
|
|
64
64
|
|
|
65
65
|
transformed = !api['lens'].nil? || !api['transform'].nil?
|
|
66
66
|
raise Internal.invalid('Lenses and transforms apply only to the rendered resource') if resource == 'original' && transformed
|
|
67
|
-
raise Internal.invalid('A placeholder takes no lens
|
|
67
|
+
raise Internal.invalid('A placeholder takes no lens or transform') if resource == 'placeholder' && transformed
|
|
68
68
|
|
|
69
69
|
url = "#{@session.cdn_base_url}/assets/#{Internal.segment(@session.project_id)}/" \
|
|
70
70
|
"#{Internal.segment(api['assetIdentifier'])}/#{resource}"
|
|
@@ -78,8 +78,14 @@ module CalmLens
|
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
query = []
|
|
81
|
-
query << "viewerClaims=#{URI.encode_www_form_component(JSON.generate(
|
|
81
|
+
query << "viewerClaims=#{URI.encode_www_form_component(JSON.generate(ordered_claims(claims)))}" if claims
|
|
82
82
|
query.empty? ? url : "#{url}?#{query.join('&')}"
|
|
83
83
|
end
|
|
84
|
+
|
|
85
|
+
# Viewer claims in the order the other SDKs send them, so equal claims build equal CDN URLs.
|
|
86
|
+
def ordered_claims(claims)
|
|
87
|
+
known = %w[region ageConfirmed].select { |key| claims.key?(key) }.to_h { |key| [key, claims[key]] }
|
|
88
|
+
known.merge(claims.except(*known.keys))
|
|
89
|
+
end
|
|
84
90
|
end
|
|
85
91
|
end
|
data/lib/calmlens/version.rb
CHANGED
data/sig/types.rbs
CHANGED
|
@@ -820,7 +820,7 @@ module CalmLens
|
|
|
820
820
|
# The asset ID or external ID.
|
|
821
821
|
asset_identifier: String,
|
|
822
822
|
# `rendered` is the default. `original` takes no lens or transform; `placeholder` is a small blurred PNG
|
|
823
|
-
#
|
|
823
|
+
# that takes no lens or transform.
|
|
824
824
|
?resource: "original" | "rendered" | "placeholder",
|
|
825
825
|
# A lens runs before any additional transforms.
|
|
826
826
|
?lens: String,
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: calmlens
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.18.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- CalmLens
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-11-
|
|
11
|
+
date: 2026-11-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: 'Server-side client for the CalmLens API: moderation, uploads, events,
|
|
14
14
|
webhooks and CDN delivery.'
|