calmlens 0.18.0 → 0.18.2

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: dc863794fe8e386bc4bf24efe9d95cd7aadb1486d4849cb1d461fdfad3055bee
4
- data.tar.gz: 5d1ef02889e701c86009c188059293d12ff6295339d1c70f7f85c1e7224b3df2
3
+ metadata.gz: 7b4f99dcd969ce76160d706032300d1a1a7c0387e57f7cf87b48c44be060bcc7
4
+ data.tar.gz: 3616af1b33e0c8da7b3dca72aa9a737e413160be42962f58220c918ea8896315
5
5
  SHA512:
6
- metadata.gz: ba3de8ddadadf1807119d23f7612c0d86287559afd8bbbb7b35c156e37a0e2f8d80dbd57c5fba4bda13878ebfaa3d6108f35656602af87e8eb880023679ca356
7
- data.tar.gz: e47c9e632c7d11c0bdde7a857fad2ddc4facf53acfa372197a868a2b21c58526b4522c68358158bc9093c76b342937b84ab7d862454814a7fa09e6095b73059b
6
+ metadata.gz: 291ccbb5dd86660f0c74867987ceb2f703fdcce5d9eb088ef064761933da68106ab965298adb7978463f7cea158b2e9c4c7e1a0d55e89c27d330ded225c7d76e
7
+ data.tar.gz: 04a08273729d5859d3a542938edcf3ac0fbac78b6125ef67033a37156339576d61dc11bfb43341ca308fd80a2bff3e4dadf4e70841ebb2f7aaa5ece4f5c5b823
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. 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.
3
+ Ruby 3.1+ server-side client for [CalmLens](https://calmlens.com) content moderation, uploads, events, webhooks and CDN delivery.
4
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.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
- ```ruby
72
- client.assets.decide(asset_id: asset_id, decision: 'blocked', note: 'Spam reported by three members')
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
- The SDK sends only the posts CalmLens uses: the most recent 5 (`CalmLens::ModerationContext::USED_ITEMS`) that fit in 2,000 characters (`CalmLens::ModerationContext::USED_CHARACTERS`, counted as Unicode code points). The newest post is always sent. `CalmLens::ModerationContext.select_items(items)` returns the posts that would be sent. The asset returns `authorId` and the `context` used; `context` is `nil` once the content is removed.
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
- File.open('video.mp4', 'rb') do |file|
93
- client.assets.create(name: 'video.mp4', content: {data: file, type: 'video/mp4'})
94
- end
14
+ gem "calmlens"
95
15
  ```
96
16
 
97
- Multipart uploads start with `part:` on `create`/`classify` 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).
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
- Use `create_signed_url` to deliver private content to browsers without your key.
19
+ ## Quick start
111
20
 
112
- `cdn.get_asset_url` and `cdn.serve` take a `resource:` of `rendered` (the default), `original` or `placeholder`; only `rendered` takes a lens or transform. `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:
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
- client.cdn.get_asset_url(asset_identifier: asset_id, transform: 'format=png,width=1024')
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
- ## Events feed
26
+ client = CalmLens::ServerClient.new(api_key: ENV.fetch('CALMLENS_API_KEY'), project_id: ENV.fetch('CALMLENS_PROJECT_ID'))
166
27
 
167
- `events.list` without `cursor:` returns **no events** and a `nextCursor` for the current time. Store it, then read on from it; persist each page's `nextCursor` once its events are handled. Unlike other lists, the events feed's `nextCursor` is never nil.
28
+ # Classify text and wait for the decision.
29
+ asset = client.text.classify(text: 'Great photo!', external_id: 'comment-42')
168
30
 
169
- ```ruby
170
- cursor = client.events.list['nextCursor'] # start from now
171
- # ...later, and on every poll:
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
- ```ruby
187
- class CalmLensWebhooksController < ActionController::API
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
- ```ruby
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
- 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.verify_webhook_request(payload:, headers:, secret:)` does the same from the request headers. `CalmLens.test_webhook_event?(event)` is true for a test send from the portal or CLI (the `{key: 'test', value: true}` fact), like `event.test?`. To test your receiver, `CalmLens.create_webhook_signature_header(payload:, timestamp:, secret:)` signs a body and `CalmLens.create_webhook_headers(payload:, delivery_id:, secret:, timestamp:)` builds the headers of a delivery.
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
- 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 exponential backoff (`retry_base_delay:`, default 0.5 seconds, doubling up to 8), half of each wait fixed and half random. A `Retry-After` in seconds or as an HTTP date replaces the backoff (`0` retries at once); one over 60 seconds is returned to you instead of waited out. Other writes are never retried, and neither are redirects. A streamed upload is retried only when its IO can seek back. `cdn.serve` retries like a read and returns the last response.
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
- `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 without real waiting; folders, hash lists, 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, including `cache-control` as the CDN sets it (`public` only when anyone may see the asset, otherwise `private`; `max-age=31536000, immutable` for images and `max-age=3600` for other content). Like the API, content is removed (`removalReason` `retention`) once classification is `completed` unless the upload sets `keep_content_after_processing: true`; a `purged` outcome removes it with `removalReason` `policy`, and a reviewer's purge (`assets.decide` or `decide`) with `requested`. `assets.decide` validates like the API: a note of 1 to 1499 characters, `confirm_purge: true` to purge, `asset/decision-not-allowed` for anything but `blocked` or `purged` while moderation is `pending`, `asset/decision-unchanged` for the current status, `asset/content-purged` for anything but `blocked` once purged, and `asset/processing` while the asset is still uploading or processing; its review and event name the reviewer `An API key`. `mock.decide` is instead a test hook for a reviewer's decision, without a note. Removing content also removes a classified image's ThumbHash and average colour. Deletes, content removals and retries record `asset.deleted`, `asset.content-removed` and `asset.retried` events, as the API does. Unset fields are `null`. Hash lists keep their entries in memory until the list is deleted, `hash_lists.add_entry(asset_id:)` uses the asset's `content.image.perceptualHash` and `perceptualHashQuality` (set them through `mock.state` to arrange a test), and uploads are not compared with them. Requests are checked in the API's order with its codes: schema errors are `validation/<issue>` with HTTP 400 and the field in `details['path']` (page sizes 1 to 100, search limits 1 to 20), list cursors only continue the query they came from, multipart parts must arrive in order with 5 MiB until the last and image totals of 10 to 20 MiB, idempotency keys are 1 to 255 printable characters and a failed request frees its key, folder names are unique among siblings (case-sensitive) and listed by code point, and a lens change needs a greater version (`details` gives `currentVersion`, `minimumVersion` and `requestedVersion`). Like the CDN, `cdn.serve` answers `403` `asset/not-available` for blocked, purged and failed assets and removed content, and `403` `asset/api-key-missing-permission` for a private asset unless `api_key:` has an `assets:download` grant for the asset's folder. It sends the CDN's `cache-tag`, `x-content-type-options: nosniff` and `content-security-policy` headers, and for images an `etag` and `expires`. The `placeholder` resource is a classified image's ThumbHash as a PNG, public whatever the asset's visibility or moderation, and refused once purged or removed. `fail_next_request` makes the next request, or the next one an operation sends, fail with `transport-error`, `timeout` or an API error code (with optional `details:` and `retry_after:` seconds) without running; `api_key_grants=` changes the key's grants. List filters support `==` and `!=` (with `*` wildcards), `=in=` and `=out=` with a `(value,value)` list, and `=gt=`, `=ge=`, `=lt=` and `=le=`, joined by `;` (and), which binds tighter than `,` (or), and grouped with parentheses, on top-level and nested names such as `classification.moderation.status`, `classification.status`, `content.status` and `content.format`; other RSQL operators raise rather than being ignored. List cursors resume after the last item returned, so assets created or deleted between pages are neither repeated nor skipped. `assets.search` runs keyword search like the API: every word of the query (case-insensitive, up to 8) must appear in the name, ID, external ID, description, tags, type, content format or extension, or moderation status, and results carry the API's keyword `score` and `matchedFields`, ranked by score and then newest first. The default clock advances at least 1 ms per reading, so assets created in a row have increasing `createdAt`; a `now:` clock you pass is used as is. Like the API, new objects get IDs with their kind's prefix, `_` and 20 random base62 characters, such as `asset_5Gm2Rk8XqT3wLp9VbN4c`; for predictable IDs, pass `create_id:`, a callable that receives the prefix (`asset`, `fldr`, `hl`, `hle`, `lens`, `evt` or `rev`) and returns the ID. It does not classify content: `moderate:` decides each asset it moderates. It receives a `CalmLens::MockModerationInput` with the stored `asset` (still processing), its `content` (`bytes`, and `text` for a plain-text upload; `nil` without content, such as a website), the `trigger` (`upload`, `retry` after `assets.retry`, `rerun` after `assets.explain` with `rerun: true`, or `complete`) and `queued` (true for queued uploads, retries, reruns and `complete`), and returns a status (`pending`, `failed`, `allowed`, `limited`, `age-restricted`, `blocked` or `purged`) or a `CalmLens::MockDecision`. Without it, queued uploads, retries and reruns stay `pending` and everything else is `allowed`. `CalmLens::MockDecision.of(status)` starts a decision; `flag(category, score, qualifiers = {})` and `not_evaluated(category)` add findings and chain, and an unknown status, category or qualifier, a score outside 0 to 1, a repeated category or findings on `pending` or `failed` raise `sdk/invalid-argument`. A decided asset gets the full report CalmLens sends: every category, the highest `score`, and `policy` with each category's action and the `flaggedCategories` (scored 0.5 or more unless `allowed`). Each completed upload records `asset.uploaded`, then `asset.classified` and the automated `asset.<status>`, or `asset.failed`. `complete_processing(asset_id, decision = nil)` finishes a processing asset with `decision` or else `moderate:`'s answer for `complete`; it raises `asset/not-found` for an unknown asset and `sdk/invalid-argument` for one that isn't processing. `decide` refuses `pending` and `failed`. `enqueue_event` with the `asset_id:` of a stored asset attaches that asset's snapshot. Each `fail_next_request` failure fails one request, so a retry takes the next queued failure or reaches the stand-in, and an unknown `operation:` raises. The mock's `max_retries:` defaults to 0. Change records through `mock.state` to arrange a test. Queued responses and errors are used first, in order. Retries and decision checks wait no real time.
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
- ## Development
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
- 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`. The contract tests replay every shared fixture in `testdata/` (packages/sdk-contract/README.md): the real client against scripted responses, the mock against the behaviour scenarios, webhooks, upload tokens and the surface. `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`.
59
+ ## What you can do
288
60
 
289
- Image moderation considers readable text directly. Optional OCR (`processingOptions.features.ocr.enabled: true` on the wire) adds a text moderation pass and stores retained text in the image’s `content.extractedText`. No separate text child is created. Read the image’s combined `classification.moderation`; removing its content also removes the extracted text.
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
- Folder listings return a cursor page like every other list: `items`, visible `ancestors`, `nextCursor`, `hasMore`, `pageSize` and `ordering`. Set `parent_id:` to open a branch (omit it for the project root), and pass `nextCursor` as `cursor:` to load the next page. `page_size:` is 1 to 100 (default 50). `search:` lists only the children whose name contains it.
74
+ Every method is listed in the [Ruby SDK documentation](https://calmlens.com/docs/sdk/ruby#methods).
292
75
 
293
- ### Viewer context for CDN delivery
76
+ ## Testing
294
77
 
295
- CDN methods accept optional viewer claims (`viewerClaims`, or the language's `viewer_claims` / `ViewerClaims` field). The object contains `region` and `ageConfirmed`, using the SDK's normal field naming. Region defaults to the country of the connection reaching CalmLens; age confirmation defaults to false. Proxies should forward the incoming viewer country rather than rely on their outgoing connection's country. CalmLens accepts these as customer assertions without independently verifying them.
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
- Age-restricted content returns `403` with `viewer/age-confirmation-required` unless age confirmation is true, including API-key and signed-URL requests. Private assets separately require access permission. Blocks and safety holds cannot be overridden by claims. Applications that never use age-restricted content should leave confirmation false, including processing jobs.
80
+ ## Links
298
81
 
299
- Raw HTTP clients can send a JSON `viewerClaims` query parameter or the JSON `X-CalmLens-Viewer-Claims` header; the query takes precedence. Signed URLs include claims when created and must be forwarded unchanged. Asset retrieval returns stored moderation results without a request-specific `delivery` object. Proxy caches must not bypass current viewer and moderation checks; honour `private, no-store` responses.
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, transform or ageConfirmed') if resource == 'placeholder' && transformed
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(api['viewerClaims']))}" if api['viewerClaims']
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CalmLens
4
4
  # The gem version. The gemspec and the User-Agent header read it from here.
5
- VERSION = '0.18.0'
5
+ VERSION = '0.18.2'
6
6
  end
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
- # and takes no lens, transform or age_confirmed.
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,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: calmlens
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.18.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - CalmLens