# Rust SDK Source: https://www.hotdata.dev/docs/rust-sdk Site index: https://www.hotdata.dev/llms.txt Official Rust client for the Hotdata HTTP API — async and strongly typed. ## Install Every call is `async`, so the crate needs a [Tokio](https://tokio.rs/) runtime. The supported Rust toolchain is listed on [GitHub](https://github.com/hotdata-dev/sdk-rust). ```bash 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: ```bash cargo add hotdata --features arrow ``` The crate builds against `native-tls` by default. To use `rustls` instead: ```bash cargo add hotdata --no-default-features --features rustls ``` ## Authentication An API token is the only credential. It is sent verbatim as `Authorization: Bearer ` on every request — there is nothing to exchange, refresh, or cache — alongside `X-Workspace-Id` on workspace-scoped calls. ```rust 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 ```rust use hotdata::prelude::*; #[tokio::main] async fn main() -> Result<(), Box> { 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 ```rust 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: ```rust 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. ```rust 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: ```rust 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 ```rust 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_REQUEST` — `a 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: ```rust 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: ```rust use hotdata::prelude::*; // result_id is Option>: 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: ```rust use hotdata::prelude::*; // Buffered — decodes every batch into a Vec 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: ```rust 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: ```rust 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 `PUT`s, 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 `PUT`s 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. ```rust 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}"), } ``` | Type | Returned by | |------|-------------| | `Error` | Every generated operation — `Reqwest`, `Serde`, `Io`, or `ResponseError` carrying the status, body, and typed error entity | | `ClientError` | `ClientBuilder::build` — a missing API token or workspace id | | `QueryError` | The `query` family — `Overloaded`, `Submit`, `AsyncRequested`, `Async`, `Poll`, and `Result(ResultError)` | | `ResultError` | Result-lifecycle failures during auto-follow — `Failed`, `Timeout`, `TooLarge`, `Incomplete`, `Unavailable` | | `AwaitResultError` | `await_result` — `Api`, `Failed`, `Timeout` | | `UploadError` | `upload_file` — `Io`, `CreateSession`, `Storage`, `StorageStatus`, `MissingETag`, `MalformedSession`, `SizeOverflow`, `Finalize`, `MintParts` | | `ArrowError` | The Arrow fetches — `NotReady`, `Failed`, `NotFound`, `InvalidParams`, `Http` | | `QueryToArrowError` | `query_to_arrow` — `Query`, `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. ```rust 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?; ``` | Handle | Description | |--------|-------------| | `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: ```rust 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>`; 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`](https://docs.rs/log) backend to see them: ```rust // RUST_LOG=hotdata::http=debug cargo run env_logger::init(); ``` ## See also - [hotdata on crates.io](https://crates.io/crates/hotdata) - [API documentation on docs.rs](https://docs.rs/hotdata) - [sdk-rust on GitHub](https://github.com/hotdata-dev/sdk-rust) - [Python SDK](/docs/python-sdk) — the same API from Python - [API Reference](/docs/api-reference) — full HTTP API documentation - [Quick Start](/docs/quick-start) — CLI and workspace setup