Rust SDK reference
This page details the Rust SDK's behaviour beyond its overview: client configuration, models, uploads, delivery and the webhook helpers. The overview lists every method and the Rust testing page covers the offline mock.
Client configuration
ServerClient::new(api_key, project_id) uses the defaults; ServerClient::builder(api_key, project_id) changes them:
use std::time::Duration;
let client = calmlens::ServerClient::builder(api_key, project_id)
.base_url("http://localhost:6110/v1") // default https://api.calmlens.com/v1
.cdn_base_url("http://localhost:6110/cdn/v1") // default https://cdn.calmlens.com/v1
.timeout(Duration::from_secs(120))
.upload_timeout(Duration::from_secs(900))
.connect_timeout(Duration::from_secs(10))
.read_timeout(Duration::from_secs(120))
.max_retries(2)
.max_response_bytes(32 * 1024 * 1024)
.build()?;The defaults and limits are exported as constants: DEFAULT_BASE_URL, DEFAULT_CDN_BASE_URL, DEFAULT_TIMEOUT, DEFAULT_UPLOAD_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_DECISION_TIMEOUT, DEFAULT_SIGNED_URL_SECONDS (900), MAX_SIGNED_URL_SECONDS (86,400), DEFAULT_UPLOAD_TOKEN_SECONDS (600), ASSET_BATCH_MAX_ASSETS (500) and ENSURE_LENSES_MAX_PER_REQUEST (100). Errors and retries describes what each timeout limits.
- An API response body over
max_response_bytes, or a declaredContent-Lengthover it, fails withsdk/response-too-largebefore the body is read. - Invalid options fail with
sdk/invalid-config. TheDebugoutput of the client and the builder never includes the API key. - Every request sends
User-Agent: calmlens-rust/<version> (rustc/<compiler version>)(calmlens::VERSIONis the crate version,calmlens::user_agent()the full value) and the API key inx-api-key; API calls also sendAccept: application/json. - Requests never follow redirects: a
3xxresponse fails withsdk/http-error, with its status and request ID, and isn't retried.
Sharing a client
Clones of a ServerClient hold an Arc and share its connection pool and lens cache, so store one in your axum or actix-web application state. Every method returns a Send future.
calmlens::blocking::ServerClient (feature blocking) runs the async client on a private single-worker Tokio runtime. Call its methods from synchronous code or tokio::task::spawn_blocking. Its paginators are Iterators, its CdnResponse implements std::io::Read, and wait_for_decision blocks until the decision or the timeout.
Models
Request and response models live in calmlens::models. Rust fields are snake_case; the JSON on the wire is camelCase.
- Response models are
#[non_exhaustive]and keep fields this SDK version doesn't know inextra. - Every enumerated value, such as
AssetClassificationStatus,AssetModerationStatus,AssetContentStatus,AssetType,Visibility,Likelihood,ClassificationCategory,EventTypeandImageFormat, keeps values it doesn't know in anUnknown(String)variant.Transform::Unknownkeeps transform types it doesn't know with their options as received. - Only metadata values and event fact values are plain JSON (
serde_json::Value), because the API accepts any JSON there.
New API fields and values therefore never fail decoding. Build response models for tests with Asset::new, AssetPage::new, Folder::new, HashList::new, Event::new and EventsPage::new, or with Default::default() and field assignment.
An Asset holds its state in upload (source content receipt, with parts_received for multipart uploads), classification (the processing status, failure_reason, labels and the moderation report) and content (whether stored content is kept, its format, size, checksums, image details, extracted text and embedded file metadata). Asset::is_decided returns whether the outcome is final. See Asset lifecycle and moderation for each status and failure reason, and Reading classification results for the report.
Updates
Update requests, such as UpdateAsset, UpdateFolder and UpdateHashList, take optional fields as Patch values: Patch::Missing (the default) leaves a field unchanged, Patch::Null clears it and Patch::Value(value) replaces it.
client
.assets()
.update(UpdateAsset { asset_id: asset.id.clone(), description: Patch::Null, ..Default::default() })
.await?;Uploads
| Constructor | Sends |
|---|---|
Content::text(text) | text/plain text |
Content::bytes(bytes, mime_type) | A Vec<u8>, bytes::Bytes or &'static [u8], without copying |
Content::reader(reader, size, mime_type) | A std::io::Read that yields exactly size bytes, read in 64 KiB chunks on Tokio's blocking pool |
Content::stream(stream, size, mime_type) | A Stream of Bytes chunks totalling size |
Content::unsized_stream(stream, mime_type) | A Stream of unknown length, sent chunked without sizeInBytes |
Content::file(path, mime_type) | A file, streamed from disk |
Content { payload: Payload::Base64(..), .. } | Base64 content or a data: URL |
A reader or stream that yields a different number of bytes than size fails with sdk/transport-error. A Content.size_in_bytes that differs from the measured payload length or the reader or stream size fails with 400 upload/invalid-payload before the request is sent.
Multipart uploads
Start with SubmitAsset::multipart(name, content.part(1, is_final)) and send later parts with assets().upload_part(UploadPart { asset_id, part: content.part(n, is_final), .. }). Non-final parts must be at least 5 MiB, and images uploaded in parts must total 10 to 20 MiB. The client refuses a part of known size before sending it: a non-final part under 5 MiB with 400 upload/invalid-payload, a first image part over 20 MiB with 400 upload/multipart-image-too-large, and a final image part that leaves the total under 10 MiB with 400 upload/multipart-image-too-small. The API checks the totals across parts with the same codes. The first request's queue choice is saved for the upload; set queue on a later part only to override it. See Multipart uploads.
Processing features
Optional analyses are off unless the upload turns them on in ProcessingOptions::features. See OCR and image reports.
let content = Content::file("photo.jpg", "image/jpeg")
.map_err(|error| calmlens::Error::new("sdk/invalid-argument", error.to_string()))?;
let asset = client
.assets()
.classify(SubmitAsset {
processing_options: Some(ProcessingOptions {
features: Some(ProcessingFeatures {
ocr: Some(FeatureOptions::enabled()),
..Default::default()
}),
..Default::default()
}),
..SubmitAsset::new("photo.jpg", content)
})
.await?;
let text = asset.content.extracted_text;Conversations
For a post in a conversation, set author_id to your identifier for its author and context to the earlier posts, oldest first. SubmitAsset and ClassifyText both take these fields. See Threaded moderation.
use calmlens::models::{ModerationContext, ModerationContextItem};
let asset = client
.text()
.classify(ClassifyText {
author_id: Some("user-9".into()),
context: Some(ModerationContext::new([
ModerationContextItem::new("Anyone going tonight?").author_id("user-3"),
ModerationContextItem::new("Not after last time").author_id("user-5"),
])),
..ClassifyText::new("Same here")
})
.await?;The SDK sends only the posts CalmLens uses: the 5 most recent (MODERATION_CONTEXT_USED_ITEMS) that fit in 2,000 characters (MODERATION_CONTEXT_USED_CHARACTERS, counted in Unicode code points). The newest post is always sent. select_moderation_context_items returns the posts that would be sent. The asset's author_id and context hold what was stored; context is None once the content is removed.
Upload tokens
upload_tokens().sign signs a single-use token for an upload preset locally, without a request. blocking::ServerClient and MockServerClient have upload_tokens() too.
let signed = client.upload_tokens().sign(SignUploadToken {
external_id: Some("post-42".into()), // optional: the uploaded asset gets this ID
expires_in_seconds: Some(600), // optional: 1 to 3600, 600 by default
..SignUploadToken::new("preset_4Lk8Qw2Xm9Rt5Vb3Nc7P")
})?;
// signed.token, signed.expires_at (milliseconds since the Unix epoch)Retries and idempotency
GET and HEAD requests, uploads and multipart parts are retried up to max_retries times after transport failures, timeouts, 429, 502, 503 and 504 responses, and 409 idempotency/request-in-progress.
- When the API sends
Retry-After, in seconds or as an HTTP date, the client waits that long;0or a past date retries at once. When it asks for more thanRETRY_AFTER_MAX(60 seconds), the client returns the error instead. - Otherwise it backs off from
RETRY_BASE_DELAY(0.5 seconds), doubling per retry up toRETRY_MAX_DELAY(8 seconds), and waits half that delay plus a random share of the other half. cdn().serveretries the same way and returns the last response when retries run out.
Each upload (assets().create, assets().classify and text().classify) and each part (assets().upload_part) is sent with a new Idempotency-Key, reused across the client's own retries, so a retried request returns the original response instead of creating a second asset. Set idempotency_key on SubmitAsset, ClassifyText or UploadPart to reuse a key across processes. Other writes, such as folders, updates, deletes and batches, are sent once. Uploads from a reader or stream are read once, so they're never retried. See Retrying requests safely.
Reviewer decisions
assets().decide(DecideAsset::new(asset_id, AssetModerationDecision::Blocked, "Spam")) records a reviewer's decision in place of the policy's. The moderation status becomes the decision, the note (1 to 1499 characters) is added to classification.moderation.manual_reviews, and the matching asset.<status> event is sent with a decided-by fact of reviewer. A reviewer decision doesn't change classification.completed_at. An asset still pending moderation can only be blocked or purged. Purging also needs confirm_purge: true and the assets:delete permission, and permanently deletes the stored content. See Human review.
Batch operations
assets().batch(BatchAssets { asset_ids, operation }) deletes up to 500 assets with AssetBatchOperation::Delete (with their child assets and stored content), or removes their stored content with AssetBatchOperation::RemoveContent, in one request.
client
.assets()
.batch(BatchAssets {
asset_ids: vec![first_id, second_id],
operation: AssetBatchOperation::RemoveContent,
})
.await?;Every asset must exist in the project and the key needs assets:delete for its folder, or nothing is queued (404 asset/not-found or 403 auth/permission-denied). The call returns BatchAssetsResponse { queued: true, .. }; the work runs in the background and records asset.deleted or asset.content-removed for each asset. Assets under a safety hold are skipped. The client refuses an empty list, more than 500 IDs or an empty ID with sdk/invalid-argument before sending.
CDN URLs
cdn().get_asset_url(&CdnRequest) builds an asset's CDN URL without a request, and cdn().serve(CdnRequest) fetches it (see CDN methods). Set init to an HttpOptions with a method and headers for a HEAD or Range request; the SDK always sends its own API key.
resourceisCdnResource::Rendered(the default),CdnResource::OriginalorCdnResource::Placeholder. Only the rendered resource takes a lens or transform.lensdelivers through a named lens.transformtakes aCdnTransform: a selector string, oneTransformstep or aVec<Transform>of steps. A selector has one path segment per step, such asfit=cover,width=640/format=webp. With both, the lens applies first, then the transform (/rendered/@thumbnail/width=320).- Structured steps become a selector with sorted, percent-encoded options, dotted nested keys (
border.color) and hex colours without#, so equal transforms share a URL.assets().create_signed_urltakes the sametransform. ImageTransformOptions::redacttakes anImageRedactionthat blurstextregions (which need OCR on the upload) orpersonal_informationregions found when the image was processed, before any other option applies. See Text regions and redaction.- An invalid transform or CDN header fails with
sdk/invalid-argument.
let url = client.cdn().get_asset_url(&CdnRequest {
asset_identifier: "asset_5Gm2Rk8XqT3wLp9VbN4c".into(),
transform: Some("format=png,width=1024".into()),
..Default::default()
})?;
let same = client.cdn().get_asset_url(&CdnRequest {
asset_identifier: "asset_5Gm2Rk8XqT3wLp9VbN4c".into(),
transform: Some(
Transform::image(ImageTransformOptions {
format: Some("png".into()),
width: Some(1024),
..Default::default()
})
.into(),
),
..Default::default()
})?;
assert_eq!(url, same);See Lenses and transforms for the options. ensure_lens and ensure_lenses create or update named lenses and return them in request order; definitions the client has already ensured are answered from its cache.
Viewer claims
viewer_claims on a CdnRequest or SignedUrlOptions takes a ViewerClaims with age_confirmed and region, sent as the JSON viewerClaims query parameter. Set age_confirmed: Some(true) only where your app has already decided the viewer may see age-restricted content. Without it, an age-restricted asset is refused with 403 viewer/age-confirmation-required, even with a signed URL or your API key. See Age-restricted assets and Viewer region.
Hash lists
Hash lists hold PDQ hashes of images the project never wants again. A HashListAction::Flag list records matches in classification.moderation.hash_matches, and a HashListAction::Block list also blocks close matches.
hash_lists().list(ListHashLists)returns a cursor page, newest first.updateclears the description withdescription: Patch::Null, anddeletealso deletes the list's entries.add_entry(AddHashListEntry)adds ahashof 64 hexadecimal characters, or an image asset's perceptual hash withasset_id; an asset without one fails with 409 hash-list/asset-not-hashed. Adding a hash the list already holds returns the existing entry.list_entriesreturns a cursor page of entries, newest first, andremove_entry(HashListEntryId)removes one.
let list = client
.hash_lists()
.create(CreateHashList {
name: "Removed images".into(),
action: HashListAction::Block,
..Default::default()
})
.await?;
client
.hash_lists()
.add_entry(AddHashListEntry {
hash_list_id: list.id.clone(),
asset_id: Some("asset_5Gm2Rk8XqT3wLp9VbN4c".into()),
label: Some("Report 1042".into()),
..Default::default()
})
.await?;Pagination details
assets().iterate, assets().iterate_children and events().pages keep the filter and ordering while they follow next_cursor. Iteration ends at a page without a next cursor, with has_more: false or without items; an error ends the stream. A page with more to follow whose cursor the iteration already sent fails with sdk/invalid-response and a cursor detail instead of repeating pages forever; that page isn't yielded.
folders().list returns a FolderPage with the folders in items and the folders above them in ancestors. Folders sort by name unless ordering names createdAt.
An Event has id, project_id, event_type, severity, title, description, asset_id, asset, facts and created_at; description, asset_id and asset are None when the event has none. To poll the feed, store the last page's next_cursor:
use futures_util::TryStreamExt;
let mut pages = client.events().pages(ListEvents { cursor: Some(cursor.clone()), page_size: Some(100) });
while let Some(page) = pages.try_next().await? {
for event in &page.items {
println!("{} {:?}", event.id, event.event_type);
}
cursor = page.next_cursor;
}
// Persist `cursor`, then poll again later.Error details
Display prefixes the message with [calmlens] , and message() returns it without the prefix. ErrorCode has a variant for each public API error code, with http_status(), and for each SDK code. Error::new(code, message) and the with_* methods build errors for tests.
| SDK code | Meaning |
|---|---|
sdk/transport-error | The request couldn't be sent or the response read |
sdk/timeout | An attempt timed out, or wait_for_decision saw no decision in time |
sdk/invalid-response | A successful response that isn't the expected JSON |
sdk/http-error | An error response without a CalmLens error body, or a redirect |
sdk/response-too-large | An API response over max_response_bytes |
sdk/invalid-argument | An empty, . or .. ID, an invalid transform or CDN header, or another invalid argument |
sdk/invalid-config | Invalid client options |
sdk/invalid-webhook-signature | A missing, wrong or stale webhook signature |
sdk/invalid-webhook-payload | A webhook body that isn't a valid delivery |
details() is empty for SDK errors such as transport failures, except where a section above names a detail.
Webhook helpers
CalmLens signs each delivery with three headers, exported as constants: WEBHOOK_SIGNATURE_HEADER (x-calmlens-signature, v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>">), WEBHOOK_TIMESTAMP_HEADER (x-calmlens-timestamp, Unix seconds) and WEBHOOK_DELIVERY_HEADER (x-calmlens-delivery, for deduplicating retries). See Webhooks.
- The helpers take the raw body as
&[u8],Bytes,Vec<u8>or&str, never a parsed JSON value, because re-serialized JSON may not match the signed bytes.construct_webhook_eventjoins repeated headers with,, so a signature list split across headers verifies. - A valid body is a JSON object with string
id,typeandprojectId, a numericcreatedAt, and other fields of the API's types. verify_webhook_headers(raw_body, &headers, secret, now_seconds, tolerance_seconds)only verifies;verify_webhook_signatureandparse_webhook_eventverify and parse separately.create_webhook_signature_headerandcreate_webhook_headerssign test deliveries, andWebhookEventPayload::from_eventbuilds the body CalmLens sends for an event.event.assetis anEventAsset, a snapshot of the asset when the event was created that holds the fields CalmLens sent. Read them withid(),external_id(),upload_preset_id(),upload_status(),classification_status(),moderation_status()andcontent_status(), orget(field).