truto 0.1.0

Rust SDK for the Truto REST API
Documentation
# truto

Rust SDK for the [Truto](https://truto.one) REST API: async (tokio), typed
models generated from Truto's OpenAPI spec, automatic retries and lazy cursor
pagination.

## Install

```sh
cargo add truto
cargo add tokio --features macros,rt-multi-thread
```

Guide: <https://truto.one/docs/sdks/rust>. API reference: <https://docs.rs/truto>.

Minimum supported Rust version: 1.88.

## Quick start

Every method returns a request builder. Chain options onto it, then `.await`
it. List methods return a lazy `Pager`.

```rust,no_run
use serde_json::json;

#[tokio::main]
async fn main() -> Result<(), truto::Error> {
    let client = truto::Client::new(std::env::var("TRUTO_API_TOKEN").unwrap());

    // List integrated accounts (typed: truto::models::IntegratedAccountResponse).
    let accounts = client
        .integrated_accounts()
        .list()
        .query(json!({ "tenant_id": "acme" }))
        .collect_all()
        .await?;
    for a in &accounts {
        println!("{:?} {:?}", a.id, a.status);
    }

    // Get one, typed.
    let account = client.integrated_accounts().get("integrated-account-id").await?;
    println!("{:?}", account.integration.and_then(|i| i.name));
    Ok(())
}
```

`Client` is cheap to clone (an `Arc` inside). Use `Client::builder()` for
settings:

```rust,no_run
use std::time::Duration;

let client = truto::Client::builder()
    .token("…")
    .base_url("https://api.truto.one") // default
    .timeout(Duration::from_secs(30)) // per attempt, default 30s
    .max_retries(3) // default 3
    .default_retry_after(Duration::from_secs(10)) // 429 without Retry-After
    .on_retry(|n, status| eprintln!("retry {n} after {status:?}"))
    .build()?;
# Ok::<(), truto::Error>(())
```

## Unified API with pagination

Unified, Proxy and Custom API items are `truto::Record`
(`serde_json::Map<String, Value>`), because their shape depends on the
integration.

```rust,no_run
use futures_util::TryStreamExt;
use truto::Query;

# async fn run(client: truto::Client) -> Result<(), truto::Error> {
// Stream every contact, fetching pages as needed.
let mut contacts = client
    .unified_api()
    .list("crm", "contacts", "integrated-account-id")
    .query(Query::new().set("limit", 100))
    .into_stream();
while let Some(contact) = contacts.try_next().await? {
    println!("{:?}", contact.get("first_name"));
}

// Or page by page.
let mut pager = client.unified_api().list("crm", "contacts", "integrated-account-id");
while let Some(page) = pager.next_page().await? {
    println!("{} items, next cursor {:?}", page.items.len(), page.next_cursor);
}

// Or everything at once / just the first item.
let all = client.unified_api().list("crm", "contacts", "ia").collect_all().await?;
let first = client.unified_api().list("crm", "contacts", "ia").first().await?;
# Ok(()) }
```

The pager copies your query and adds `next_cursor` for each following page; it
stops when the server returns no cursor. Responses carrying `results` instead
of `result` are handled too.

## Proxy and Custom APIs

```rust,no_run
use serde_json::json;

# async fn run(client: truto::Client) -> Result<(), truto::Error> {
let ticket = client.proxy_api().get("tickets", "123", "ia").await?;
let created = client
    .proxy_api()
    .create("tickets", "ia", json!({ "subject": "Hello" }))
    .await?;

// Custom API: path may contain `/`. Non-JSON responses: use `.raw()`.
let report = client.custom_api().get("reports/export.csv", "ia").raw().await?;
println!("{} bytes of {:?}", report.body.len(), report.content_type());
# Ok(()) }
```

## Query parameters

`.query(..)` takes a `serde_json::Value` object, a `serde_json::Map`, or a
`truto::Query`. Nested values are encoded like the `qs` package
(`filter[status]=open`, `ids[0]=a`), keys sorted. Repeated `.query` calls merge.

Admin list endpoints filter on related fields with a dotted key, not a nested
object: the API rejects `integration[name]=...` but accepts `integration.name=...`.

```rust,no_run
# use serde_json::json;
# async fn run(client: truto::Client) -> Result<(), truto::Error> {
let zoho_accounts = client
    .integrated_accounts()
    .list()
    .query(json!({ "tenant_id": "acme", "integration.name": "zohocrm" }))
    .collect_all()
    .await?;
# Ok(()) }
```

## Error handling

All methods return `Result<T, truto::Error>`:

```rust,no_run
use truto::{Error, ErrorBody};

# async fn run(client: truto::Client) {
match client.tenants().get("acme").await {
    Ok(tenant) => println!("{tenant:?}"),
    Err(e) if e.is_not_found() => println!("no such tenant"),
    Err(e) if e.is_unauthorized() => println!("bad token"),
    Err(Error::Api(api)) => {
        // status, status_text, parsed body (JSON or text), response headers
        println!("{} {}: {:?}", api.status, api.status_text, api.body);
    }
    Err(Error::Timeout) => println!("timed out"),
    Err(Error::Transport(e)) => println!("network: {e}"),
    Err(Error::Decode { message, .. }) => println!("unexpected response: {message}"),
    Err(e) => println!("{e}"),
}
# }
```

## Retries and timeouts

Requests are retried on 408, 409, 425, 429, 502, 503 and 504, and on
connection errors (never on timeouts), up to `max_retries` times. A 429 waits
for the `Retry-After` header (in seconds, default 10s); other statuses back off
exponentially with full jitter (500ms base, 10s cap). Dropping the future
cancels any wait.

Every request can override these:

```rust,no_run
use std::time::Duration;

# async fn run(client: truto::Client) -> Result<(), truto::Error> {
let ia = client
    .integrated_accounts()
    .get("ia")
    .timeout(Duration::from_secs(5))
    .max_retries(5)
    .retry_status_codes([500])
    .header("x-request-tag", "nightly-sync")
    .on_retry(|count, status| eprintln!("retry {count} ({status:?})"))
    .on_rate_limited(|after, count| eprintln!("429: waiting {after}s (retry {count})"))
    .await?;

let once = client.integrated_accounts().get("ia").no_retry().await?;
# Ok(()) }
```

Reuse a set of overrides with `truto::RequestOptions` and `.options(opts)`.

## Typed decode

Admin resources return generated models from `truto::models`. Every struct
decodes leniently (unknown fields ignored, missing fields defaulted), enums
are open (an unknown value becomes `Other(String)` and serialises back
unchanged), and union types keep the raw JSON with an `as_<variant>()`
accessor per variant.

For dynamic payloads, decode into your own types with `.typed::<T>()` on any
request or pager, or `truto::decode` on a record:

```rust,no_run
#[derive(serde::Deserialize)]
struct Contact {
    id: String,
    first_name: Option<String>,
}

# async fn run(client: truto::Client) -> Result<(), truto::Error> {
let contacts: Vec<Contact> = client
    .unified_api()
    .list("crm", "contacts", "ia")
    .typed::<Contact>()
    .collect_all()
    .await?;

let record = client.unified_api().get("crm", "contacts", "c1", "ia").await?;
let contact: Contact = truto::decode(record)?;
# Ok(()) }
```

## Resources

`client.<resource>()` for: `api_tokens`, `environments`, `categories`,
`teams`, `users`, `tenants`, `link_tokens`, `session_tokens`,
`integrated_accounts`, `sandbox_integrated_accounts`, `integrations`,
`environment_integrations`, `unified_models`, `environment_unified_models`,
`unified_model_resource_methods`, `environment_unified_model_resource_methods`,
`sync_jobs`, `sync_job_runs`, `sync_job_cron_triggers`, `sync_job_templates`,
`sync_job_run_states`, `daemons`, `daemon_jobs`, `daemon_job_runs`,
`daemon_job_cron_triggers`, `alarms`, `workflows`, `workflow_runs`, `webhooks`,
`datastores`, `documentation`, `logs`, `static_gates`,
`observability_destinations`, `analytics`, `batch_requests`,
`environment_integration_webhooks`, `integrated_account_webhooks`,
`unified_api`, `proxy_api`, `custom_api`, `mcp`; plus `client.ping()`.

`logs().list(log_type)` takes the `log_type` the server requires:
`unified_proxy_api`, `rapid_bridge`, `webhook`, `mcp` or `sync_job_cron_trigger`.

## Examples

```sh
TRUTO_API_TOKEN=... cargo run --example list_integrated_accounts -- <tenant_id>
TRUTO_API_TOKEN=... cargo run --example unified_api -- <integrated_account_id>
TRUTO_API_TOKEN=... cargo run --example proxy_and_errors -- <integrated_account_id>
```

## Regenerating the models

`src/models/generated.rs` is generated from `spec/openapi.json`. After
updating the spec:

```sh
python3 codegen/normalize_spec.py spec/openapi.json spec/overlay.json > codegen/models.json
python3 codegen/generate_rust.py codegen/models.json > src/models/generated.rs
```

Both scripts need only Python 3. Do not edit the generated file by hand.

## Development

```sh
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test
```

Tests run against a local mock server; no network or token is needed.

## License

MIT. See [LICENSE](LICENSE).