IntegrationsRust SDK

Rust SDK

Official Rust client for the Hotdata HTTP API — async and strongly typed.

Install

Every call is async, so the crate needs a Tokio runtime. The supported Rust toolchain is listed on GitHub.

cargo add hotdata
cargo add tokio --features macros,rt-multi-thread

Apache Arrow result support (faster and more memory-efficient for large result sets) is behind an optional feature:

cargo add hotdata --features arrow

The crate builds against native-tls by default. To use rustls instead:

cargo add hotdata --no-default-features --features rustls

Authentication

An API token is the only credential. It is sent verbatim as Authorization: Bearer <token> on every request — there is nothing to exchange, refresh, or cache — alongside X-Workspace-Id on workspace-scoped calls.

use hotdata::prelude::*;

let client = Client::builder()
    .api_token("YOUR_API_TOKEN")
    .workspace_id("your_workspace_id")
    .build()?;

Both are optional on the builder: when omitted they fall back to the HOTDATA_API_KEY and HOTDATA_WORKSPACE_ID environment variables, and build() returns ClientError::MissingApiToken / ClientError::MissingWorkspaceId if neither is set.

base_url defaults to https://api.hotdata.dev (or HOTDATA_API_URL). Override it with .base_url(..) to target another environment.

Quickstart

use hotdata::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::builder()
        .api_token("YOUR_API_TOKEN")
        .workspace_id("your_workspace_id")
        .build()?;

    // Queries, results, and query runs are scoped to a database via the
    // required X-Database-Id header, so pick one first.
    let database_id = "dbid6lguax1dxn9y1xj5gxnameyywl";

    let response = client
        .query_in(QueryRequest::new("SELECT 1 AS ok".to_string()), database_id)
        .await?;

    println!("{:?} {:?}", response.columns, response.rows);
    Ok(())
}

Instant databases

use hotdata::prelude::*;

// Create a database and declare schemas and tables on its default catalog
let created = client
    .databases()
    .create(CreateDatabaseRequest {
        name: field::set("sales"),
        expires_at: field::set("24h"),
        schemas: Some(vec![DatabaseDefaultSchemaDecl {
            name: "public".to_string(),
            tables: Some(vec![
                DatabaseDefaultTableDecl::new("orders".to_string()),
                DatabaseDefaultTableDecl::new("customers".to_string()),
            ]),
        }]),
        ..CreateDatabaseRequest::new()
    })
    .await?;

println!("{} {}", created.id, created.default_connection_id);

// List databases, newest first — (limit, cursor, search, batch)
let listing = client.databases().list(Some(20), None, None, None).await?;
for db in &listing.databases {
    println!("{} {:?}", db.id, db.name);
}

// Fetch one, then delete it
let detail = client.databases().get(&created.id).await?;
println!("{}", detail.default_connection_id);
client.databases().delete(&created.id).await?;

Tables declared at create time live on the database's auto-provisioned default catalog, addressed by created.default_connection_id. To add a schema or table to a database that already exists, reach for the generated operations:

use hotdata::apis::connections_api;
use hotdata::models;

let config = client.configuration();
let connection_id = &created.default_connection_id;

connections_api::add_managed_schema(
    config,
    connection_id,
    models::AddManagedSchemaRequest::new("staging".to_string()),
)
.await?;

connections_api::add_managed_table(
    config,
    connection_id,
    "staging",
    models::AddManagedTableRequest::new("orders_raw".to_string()),
)
.await?;

Load a file into a managed table

upload_file runs the whole presigned direct-to-storage flow — open a session, PUT the bytes straight to storage, finalize — and hands back an upload_id to load from.

use hotdata::prelude::*;

let upload = client
    .upload_file(
        "orders.parquet",
        UploadOptions {
            content_type: Some("application/parquet".to_string()),
            ..UploadOptions::default()
        },
    )
    .await?;

let loaded = client
    .connections()
    .load_managed_table(
        &created.default_connection_id,
        "public",
        "orders",
        LoadManagedTableRequest {
            upload_id: field::set(upload.upload_id.clone()),
            ..LoadManagedTableRequest::new("replace".to_string())
        },
    )
    .await?;

println!("{} rows into {}", loaded.row_count, loaded.table_name);

mode is "replace" to overwrite the table's contents or "append" to add to them; "delete", "update", and "upsert" match rows by the table's key columns. A large load should set r#async: Some(true) and poll the returned job with client.jobs().

Send rows inline

For a small table, skip the upload entirely and put CSV text — header row included, up to 2 MiB — in the request:

let loaded = client
    .connections()
    .load_managed_table(
        &created.default_connection_id,
        "public",
        "customers",
        LoadManagedTableRequest {
            data: field::set("id,name\n1,Ada\n2,Grace\n"),
            idempotency_key: field::set("customers-batch-1"),
            ..LoadManagedTableRequest::new("append".to_string())
        },
    )
    .await?;

Column types are detected from the data unless columns declares them. idempotency_key — valid only with inline data — makes the load safe to retry: send the same key again and the rows land at most once.

Execute SQL

use hotdata::prelude::*;

// Scoped to a database, with 429 retry and truncated-result auto-follow
let response = client
    .query_in(
        QueryRequest::new("SELECT * FROM orders LIMIT 10".to_string()),
        database_id,
    )
    .await?;

// Just the bounded inline preview — no auto-follow of a truncated result.
// query_preview takes no database argument, so the scope travels in the body.
let preview = client
    .query_preview(QueryRequest {
        database_id: field::set(database_id),
        ..QueryRequest::new("SELECT * FROM large_table".to_string())
    })
    .await?;
if preview.truncated {
    println!("preview only; page the full set via result_id");
}

// Asynchronous submission — returns an acknowledgement to poll
let outcome = client
    .submit_query(
        QueryRequest {
            r#async: Some(true),
            async_after_ms: field::set(3000),
            ..QueryRequest::new("SELECT * FROM large_table".to_string())
        },
        Some(database_id),
    )
    .await?;

// QueryOutcome is #[non_exhaustive], so the match needs a wildcard arm
match outcome {
    QueryOutcome::Inline(response) => println!("{} rows inline", response.row_count),
    QueryOutcome::Submitted(ack) => println!("poll query run {}", ack.query_run_id),
    _ => println!("unrecognized outcome"),
}

Every query is database-scoped, and the API rejects one that names no database with a 400 BAD_REQUESTa database is required: set the X-Database-Id header or the database_id body field. query_in, query_with, and submit_query take the id and send it as X-Database-Id; Client::query and Client::query_preview do not, so with those two the scope has to travel in the request's database_id field, as above. Send exactly one — a header and a body field that disagree are a 400. Giving those two shortcuts a database argument is folded into a future SDK parity pass.

query_in transparently retries HTTP 429 (OVERLOADED) and, when the server truncates a large result, pages the full row set into response.rows — bounded by the client's QueryConfig (1M rows / 64 MiB by default), so a runaway result is an error rather than an OOM. Clone the config to tune it per call:

let config = client.query_config().clone().with_auto_follow(false);
let response = client
    .query_with(
        QueryRequest::new("SELECT * FROM orders".to_string()),
        Some(database_id),
        &config,
    )
    .await?;

Persisted results

A query returns rows inline and a result_id that persists asynchronously. await_result polls it to ready without a hand-rolled loop:

use hotdata::prelude::*;

// result_id is Option<Option<String>>: absent, or explicitly null when the
// result could not be persisted (see response.warning).
if let Some(result_id) = response.result_id.flatten() {
    let ready = client
        .await_result(&result_id, database_id, PollConfig::default())
        .await?;

    if ready.result_status().is_ready() {
        println!("{:?}", ready.rows);
    }
}

PollConfig::default() is a 120-second timeout polled every second. result_status() and run_status() read the wire's plain-string statuses as the typed ResultStatus / QueryRunStatus enums, each carrying an Other(String) variant so a status added later round-trips instead of breaking deserialization.

Apache Arrow results

With the arrow feature on, fetch a result as an Arrow IPC stream instead of JSON:

use hotdata::prelude::*;

// Buffered — decodes every batch into a Vec<RecordBatch>
let arrow = client
    .get_result_arrow(&result_id, database_id, None, None)
    .await?;
println!("{:?} / {:?} rows", arrow.schema, arrow.total_row_count);
for batch in &arrow.batches {
    println!("{} rows", batch.num_rows());
}

// Streaming — yields batches lazily, without holding them all at once
let stream = client
    .stream_result_arrow(&result_id, database_id, None, None)
    .await?;
for batch in stream {
    let batch = batch?;
    println!("{} rows", batch.num_rows());
}

Both take offset and limit for pagination and return ArrowError::NotReady while the result is still pending — poll client.get_result(&result_id, database_id) until its status is ready first, or use await_result.

To run a query and decode its result as Arrow in a single call — submit, await ready, decode:

let arrow = client
    .query_to_arrow(
        QueryRequest::new("SELECT * FROM big_table".to_string()),
        database_id,
        PollConfig::default(),
        None, // offset
        None, // limit
    )
    .await?;

Upload options

upload_file picks its strategy from the file's size: a single PUT for a small file, and for a large one a multipart upload that mints each part URL just before uploading that part, so a presigned URL cannot expire mid-transfer. UploadOptions tunes the rest, and every field is optional:

use std::sync::Arc;
use hotdata::prelude::*;

let upload = client
    .upload_file(
        "events.csv.gz",
        UploadOptions {
            content_type: Some("text/csv".to_string()),
            content_encoding: Some("gzip".to_string()),
            filename: Some("events.csv.gz".to_string()),
            part_size: Some(16 * 1024 * 1024),
            max_concurrency: Some(8),
            progress: Some(Arc::new(|done, total| {
                println!("{done}/{total} bytes");
            })),
        },
    )
    .await?;

content_type, content_encoding, and filename are advisory metadata recorded with the upload. part_size is a hint the server clamps to its own range and ignores for single-PUT uploads; left unset, the SDK scales one itself — 8 MiB, larger only for very large files, to keep the part count bounded. max_concurrency caps in-flight part PUTs, and the effective count is the lower of it and a peak-memory budget derived from the server's actual part size, so memory stays bounded whatever you pass.

A multi-gigabyte upload legitimately takes minutes, and storage PUTs reuse the configured reqwest client, so supply one with no request timeout via ClientBuilder::reqwest_client when uploading large files.

Error handling

Every error type in the SDK implements std::error::Error, so {err} and the source() chain are always meaningful. The SDK's own enums are #[non_exhaustive] — match them with a wildcard arm.

use hotdata::prelude::*;
use hotdata::Error;

match client
    .query_in(
        QueryRequest::new("SELECT * FROM missing_table".to_string()),
        database_id,
    )
    .await
{
    Ok(response) => println!("{} rows", response.row_count),
    Err(QueryError::Overloaded { attempts, .. }) => {
        println!("server shedding load; gave up after {attempts} attempt(s)")
    }
    Err(QueryError::Submit(Error::ResponseError(response))) => {
        println!("API error {}: {}", response.status, response.content)
    }
    Err(other) => println!("query failed: {other}"),
}
TypeReturned by
Error<T>Every generated operation — Reqwest, Serde, Io, or ResponseError carrying the status, body, and typed error entity
ClientErrorClientBuilder::build — a missing API token or workspace id
QueryErrorThe query family — Overloaded, Submit, AsyncRequested, Async, Poll, and Result(ResultError)
ResultErrorResult-lifecycle failures during auto-follow — Failed, Timeout, TooLarge, Incomplete, Unavailable
AwaitResultErrorawait_resultApi, Failed, Timeout
UploadErrorupload_fileIo, CreateSession, Storage, StorageStatus, MissingETag, MalformedSession, SizeOverflow, Finalize, MintParts
ArrowErrorThe Arrow fetches — NotReady, Failed, NotFound, InvalidParams, Http
QueryToArrowErrorquery_to_arrowQuery, NoResultId, Timeout, Arrow

Resource handles

The generator emits free functions; the client groups them into workspace-scoped handles so you never pass a Configuration around.

let connections = client.connections().list().await?;
let secrets = client.secrets().list().await?;
// Query runs are database-scoped — (database_id, limit, cursor, status, saved_query_id)
let runs = client
    .query_runs()
    .list(database_id, Some(50), None, None, None)
    .await?;
HandleDescription
queries()Execute SQL — plus the query, query_in, query_preview, query_with, and submit_query shortcuts on Client
databases()Create, fork, list, count, and delete instant databases; attach catalogs
connections()Inspect connections and load managed tables
database_context()Read and write a database's stored context
information_schema()List tables and columns
query_runs()Inspect query run history
results()Retrieve stored query results
uploads()Upload files for managed table loads
saved_queries()Create, execute, and version saved queries
indexes()Create and list indexes (BM25, vector)
embedding_providers()Manage embedding providers
secrets()Manage workspace secrets
jobs()Monitor background jobs
refresh()Refresh source tables
workspaces()List, create, and delete workspaces
connection_types()Describe the available connector types

Anything not yet wrapped is one call away through the full generated surface:

use hotdata::apis::workspaces_api;

let workspaces = workspaces_api::list_workspaces(client.configuration(), None).await?;

Request and response types live under hotdata::models, and hotdata::prelude re-exports the client, the handles, and every model. Several requests model a field that is both optional and nullable as Option<Option<T>>; the field helpers name the three intents — field::set(value) to set it, field::clear() to send null, and None to omit it.

Debug logging

Every HTTP call emits log::debug! records on the hotdata::http target — the request line, headers, and body, then the response status and body, with bearer tokens and sensitive fields masked. The SDK installs no logger, so wire up any log backend to see them:

// RUST_LOG=hotdata::http=debug cargo run
env_logger::init();

See also