# Rust SDK reference

This page details the [Rust SDK](/docs/sdk/rust)'s behaviour beyond its overview: client configuration, models, uploads, delivery and the webhook helpers. The overview lists every method and the [Rust testing](/docs/sdk/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:

```rust
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](/docs/sdk/rust#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 `Iterator`s, 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](/docs/guides/asset-lifecycle) for each status and failure reason, and [Reading classification results](/docs/guides/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.

```rust
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`](/docs/api/assets#create-asset-error-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`](/docs/api/assets#create-asset-error-upload-invalid-payload), a first image part over 20 MiB with [`400 upload/multipart-image-too-large`](/docs/api/assets#create-asset-error-upload-multipart-image-too-large), and a final image part that leaves the total under 10 MiB with [`400 upload/multipart-image-too-small`](/docs/api/assets#create-asset-error-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](/docs/guides/multipart-uploads).

### Processing features

Optional analyses are off unless the upload turns them on in `ProcessingOptions::features`. See [OCR and image reports](/docs/guides/ocr).

```rust
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](/docs/guides/threaded-moderation).

```rust
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](/docs/guides/upload-presets#upload-tokens) locally, without a request. `blocking::ServerClient` and `MockServerClient` have `upload_tokens()` too.

```rust
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`](/docs/api/assets#create-asset-error-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](/docs/guides/error-handling#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](/docs/guides/human-review#decisions).

## 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.

```rust
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`](/docs/api/assets#upload-asset-part-error-asset-not-found) or [`403 auth/permission-denied`](/docs/api/assets#create-asset-error-auth-permission-denied)). The call returns `BatchAssetsResponse { queued: true, .. }`; the work runs in the background and records [`asset.deleted`](/docs/api/events#event-asset-deleted) or [`asset.content-removed`](/docs/api/events#event-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](/docs/sdk/rust#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](/docs/guides/ocr#text-regions-and-redaction).
- An invalid transform or CDN header fails with `sdk/invalid-argument`.

```rust
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](/docs/guides/lenses) 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`](/docs/api/cdn#error-viewer-age-confirmation-required), even with a signed URL or your API key. See [Age-restricted assets](/docs/guides/delivering-assets#age-restricted-assets) and [Viewer region](/docs/guides/jurisdiction-rules#viewer-region).

## Hash lists

[Hash lists](/docs/guides/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`](/docs/api/hash-lists#create-hash-list-entry-error-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.

```rust
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`:

```rust
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](/docs/guides/webhooks#headers).

- 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)`.
