truto 0.1.0

Rust SDK for the Truto REST API
Documentation

truto

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

Install

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.

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:

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.

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

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

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

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:

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:

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

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:

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

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.