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 declared Content-Length over it, fails with sdk/response-too-large before the body is read.
  • Invalid options fail with sdk/invalid-config. The Debug output of the client and the builder never includes the API key.
  • Every request sends User-Agent: calmlens-rust/<version> (rustc/<compiler version>) (calmlens::VERSION is the crate version, calmlens::user_agent() the full value) and the API key in x-api-key; API calls also send Accept: application/json.
  • Requests never follow redirects: a 3xx response fails with sdk/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 in extra.
  • Every enumerated value, such as AssetClassificationStatus, AssetModerationStatus, AssetContentStatus, AssetType, Visibility, Likelihood, ClassificationCategory, EventType and ImageFormat, keeps values it doesn't know in an Unknown(String) variant. Transform::Unknown keeps 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

ConstructorSends
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; 0 or a past date retries at once. When it asks for more than RETRY_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 to RETRY_MAX_DELAY (8 seconds), and waits half that delay plus a random share of the other half.
  • cdn().serve retries 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.

  • resource is CdnResource::Rendered (the default), CdnResource::Original or CdnResource::Placeholder. Only the rendered resource takes a lens or transform.
  • lens delivers through a named lens. transform takes a CdnTransform: a selector string, one Transform step or a Vec<Transform> of steps. A selector has one path segment per step, such as fit=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_url takes the same transform.
  • ImageTransformOptions::redact takes an ImageRedaction that blurs text regions (which need OCR on the upload) or personal_information regions 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. update clears the description with description: Patch::Null, and delete also deletes the list's entries.
  • add_entry(AddHashListEntry) adds a hash of 64 hexadecimal characters, or an image asset's perceptual hash with asset_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_entries returns a cursor page of entries, newest first, and remove_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 codeMeaning
sdk/transport-errorThe request couldn't be sent or the response read
sdk/timeoutAn attempt timed out, or wait_for_decision saw no decision in time
sdk/invalid-responseA successful response that isn't the expected JSON
sdk/http-errorAn error response without a CalmLens error body, or a redirect
sdk/response-too-largeAn API response over max_response_bytes
sdk/invalid-argumentAn empty, . or .. ID, an invalid transform or CDN header, or another invalid argument
sdk/invalid-configInvalid client options
sdk/invalid-webhook-signatureA missing, wrong or stale webhook signature
sdk/invalid-webhook-payloadA 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_event joins repeated headers with ,, so a signature list split across headers verifies.
  • A valid body is a JSON object with string id, type and projectId, a numeric createdAt, and other fields of the API's types.
  • verify_webhook_headers(raw_body, &headers, secret, now_seconds, tolerance_seconds) only verifies; verify_webhook_signature and parse_webhook_event verify and parse separately.
  • create_webhook_signature_header and create_webhook_headers sign test deliveries, and WebhookEventPayload::from_event builds the body CalmLens sends for an event.
  • event.asset is an EventAsset, a snapshot of the asset when the event was created that holds the fields CalmLens sent. Read them with id(), external_id(), upload_preset_id(), upload_status(), classification_status(), moderation_status() and content_status(), or get(field).

Last updated

Signing in

Signing out

Cookies on CalmLens

We use essential cookies for sign-in and security. With your permission, functional cookies remember your preferences and Google Analytics measures website use and signups. Optional ad measurement cookies link Google ad clicks to signups. Personalised advertising stays off. Manage cookies