# Rust SDK

Use the async Rust client, or its blocking counterpart, for typed moderation requests, events and content delivery. Rust 1.88 or later is required.

## Choose a moderation flow

Use the SDK's moderate method to wait for a decision in the request, or its create method to queue moderation and let your app continue. Text and images support both flows. Audio and video always run asynchronously, even when using moderate.

For queued content, save the returned asset ID and [set up a webhook](/docs/guides/webhooks) for [`asset.classified`](/docs/api/events#event-asset-classified) (decision ready) and [`asset.failed`](/docs/api/events#event-asset-failed) (processing failed). Keep content unpublished until approved. You can retrieve the asset periodically instead of receiving events. The [quick start](/docs) demonstrates both flows.

## Install

> The Rust SDK is not yet available from crates.io.

The default features are `async` (the async `Client`) and `rustls` (TLS with bundled root certificates). Other features:

| Feature | Adds |
| --- | --- |
| `blocking` | `calmlens::blocking::Client`, the same API without `async` |
| `rustls-native-roots` | TLS with the operating system's root certificates |
| `native-tls` | TLS through the platform library |
| `testing` | `calmlens::testing::MockClient`, for `[dev-dependencies]` only |

With `default-features = false`, the crate contains only the models and the webhook helpers, without an HTTP client.

## Moderate content

Keep credentials on your server. Create one client when your application starts and share it: clones are cheap and share connections, so store it in your application state rather than creating one per request.

```rust
use calmlens::{models::*, Client};

#[tokio::main]
async fn main() -> calmlens::Result<()> {
    let client = Client::new("your-api-key", "your-project-id")?;
    let asset = client.assets().moderate(SubmitAsset::text("Hello world")).await?;
    if asset.status == Some(AssetStatus::Approved) {
        println!("{}", asset.id);
    }
    let queued = client
        .assets()
        .create(SubmitAsset::new("note.txt", Content::bytes(b"Hello".to_vec(), "text/plain")))
        .await?;
    client
        .assets()
        .update(UpdateAsset { asset_id: queued.id, description: Patch::Null, ..Default::default() })
        .await?;
    Ok(())
}
```

Publish only an explicit approved result. An approved, flagged asset may still need review. See [Reading reports](/docs/guides/moderation-reports).

`AssetStatus` and `Visibility` have an `Unknown` variant, and every response model keeps fields this SDK version doesn't know in `extra`, so new API values never fail decoding. Treat an unknown status as not approved.

## Blocking applications

Enable the `blocking` feature for `calmlens::blocking::Client`. It runs the async client on a private runtime, so you can create and drop it anywhere, including inside an async runtime. Its calls block the current thread, so don't call them from async code; use the async client there.

```rust
use calmlens::{blocking::Client, models::*};

fn main() -> calmlens::Result<()> {
    let client = Client::new("your-api-key", "your-project-id")?;
    for asset in client.assets().iterate(ListAssets::default()) {
        println!("{}", asset?.id);
    }
    Ok(())
}
```

## Pagination and events

`assets().iterate`, `assets().iterate_children` and `events().pages` fetch the next page as you read. The async client returns a `Stream`; the blocking client returns an `Iterator`.

```rust
use futures_util::TryStreamExt;

let mut assets = client.assets().iterate(ListAssets { page_size: Some(100), ..Default::default() });
while let Some(asset) = assets.try_next().await? {
    println!("{}", asset.id);
}
```

`events().pages(ListEvents { after: Some(cursor), .. })` follows each page's cursor until a page has `has_more: false`. Store the last page's `cursor` and continue from it on the next poll. Without `after`, the first page is empty and its cursor marks the current time.

## Uploads and delivery

`Content::bytes`, `Content::text`, `Content::reader(reader, size, mime_type)`, `Content::stream(stream, size, mime_type)` and `Content::file(path, mime_type)` create upload content. Readers, streams and files are sent as they are read, so the whole file never needs to be in memory.

For a multipart upload, send the first part with `SubmitAsset::multipart(name, content.part(1, is_final))` and later parts with `assets().upload_part`. Set `total_size_in_bytes` on the first request when you know the file's size. CalmLens saves the queue choice when the upload starts, so any process that knows the asset ID can send the remaining parts. See [Multipart uploads](/docs/guides/multipart-uploads).

`cdn().serve(CdnRequest)` returns a streaming `CdnResponse` whatever its status. Check `status()`, then read it with `chunk()`, `into_stream()` or `bytes()`; the blocking response implements `std::io::Read`. Drop an unfinished response to close it. Use signed URLs for private content in browsers.

## Retries and idempotency

The client retries GET requests, uploads and multipart parts up to twice after network failures, timeouts and `429`, `502`, `503` and `504` responses, and while a request with the same idempotency key is still in progress. It waits for the `Retry-After` delay when the API sends one, and otherwise for a short random backoff. Configure it with `Client::builder(key, project).max_retries(n)`; `0` turns retries off.

Each upload and part is sent with an `Idempotency-Key`, and the client reuses it for its own retries, so a retried upload never creates a second asset. Set `idempotency_key` on `SubmitAsset` or `UploadPart` to retry safely across processes. Uploads from readers and streams are sent once. See [Retrying requests safely](/docs/guides/error-handling#retrying-requests-safely).

## Errors and timeouts

Every call returns `calmlens::Error`:

| Method | Returns |
| --- | --- |
| `code()` | An `ErrorCode`, such as `ErrorCode::AssetNotFound`, or `ErrorCode::Other` for codes this SDK version doesn't know. Compare with a string: `error.code() == "asset/not-found"` |
| `status()` | The HTTP status of an API error |
| `request_id()` | The response's `X-Request-Id`; include it when you contact support |
| `retry_after()` | The `Retry-After` delay of a `429` or in-progress response |
| `details()`, `headers()`, `source()` | The API's details, the response headers and the underlying error |
| `is_not_found()`, `is_retryable()`, `is_timeout()` | Common checks |

Network failures use `sdk/transport-error` and timeouts `sdk/timeout`. Invalid client options return `sdk/invalid-config`, and invalid method arguments, such as an empty asset ID, `sdk/invalid-argument`. Upload content is checked before it's sent and uses the API's codes, such as `upload/invalid-payload`.

API calls time out after 60 seconds by default (`timeout`). Uploads and CDN downloads have no overall limit, so large files are not cut off; instead, every request fails if no data arrives for 60 seconds (`read_timeout`) or a connection takes longer than 10 seconds (`connect_timeout`). Use `client.with_timeout(duration)` to bound one call, including an upload. Pass your own `reqwest::Client` with `ClientBuilder::http_client` for a proxy or custom certificates.

## Webhooks

Verify the raw request body before parsing it. `construct_webhook_event(raw_body, &headers, secret)` checks the signature and the timestamp (within `WEBHOOK_SIGNATURE_TOLERANCE_SECONDS`, 300 seconds) and returns a `models::WebhookEventPayload`. A missing, wrong or stale signature returns `sdk/invalid-webhook-signature`; a body that isn't a delivery returns `sdk/invalid-webhook-payload`.

With axum, take the body as `Bytes`, never a parsed `Json` extractor:

```rust
use axum::{body::Bytes, http::{HeaderMap, StatusCode}};

async fn calmlens_webhook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let secret = std::env::var("CALMLENS_WEBHOOK_SECRET").unwrap_or_default();
    match calmlens::construct_webhook_event(&body, &headers, &secret) {
        Ok(event) => {
            // Queue work keyed by event.id, then respond quickly.
            println!("{} {:?}", event.event_type, event.data.asset_id);
            StatusCode::NO_CONTENT
        }
        Err(error) if error.code() == "sdk/invalid-webhook-signature" => StatusCode::UNAUTHORIZED,
        Err(_) => StatusCode::BAD_REQUEST,
    }
}
```

With actix-web, take the body as `web::Bytes` and pass the header values to `construct_webhook_event_at`:

```rust
use actix_web::{web, HttpRequest, HttpResponse};

async fn calmlens_webhook(request: HttpRequest, body: web::Bytes) -> HttpResponse {
    let header = |name: &str| request.headers().get(name).and_then(|v| v.to_str().ok()).unwrap_or("");
    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
    let secret = std::env::var("CALMLENS_WEBHOOK_SECRET").unwrap_or_default();
    match calmlens::construct_webhook_event_at(
        &body,
        header(calmlens::WEBHOOK_SIGNATURE_HEADER),
        header(calmlens::WEBHOOK_TIMESTAMP_HEADER),
        &secret,
        now,
        calmlens::WEBHOOK_SIGNATURE_TOLERANCE_SECONDS,
    ) {
        Ok(_event) => HttpResponse::NoContent().finish(),
        Err(_) => HttpResponse::Unauthorized().finish(),
    }
}
```

The payload has `id`, `event_type`, `created_at`, `project_id` and `data` (`severity`, `title`, `description`, `asset_id`, the `asset` snapshot and `facts`). Deduplicate by event ID, and retrieve the asset for its current state rather than trusting the snapshot. `verify_webhook_signature` and `parse_webhook_event` are available separately. See [Webhooks](/docs/guides/webhooks).

## Test without API calls

See [Rust testing](/docs/sdk/rust/testing) for offline mock setup, request inspection and simulated failures.

## Local development

Use the standalone [CalmLens CLI](/docs/guides/local-development) to forward events locally, regardless of your application language.
