# cirrus
An ergonomic Rust HTTP client for the Salesforce REST API.
This project is in no way affiliated with Salesforce.
`cirrus` is a strongly-typed, async-first client built on `reqwest` and
`tokio`. It covers the everyday surface of the Salesforce REST API — CRUD,
SOQL/SOSL, Bulk 2.0, composite, Tooling, Apex REST, Event Monitoring — plus a
small set of cross-cutting niceties (retry/backoff, auto-refresh on 401, a
streaming pagination iterator, conditional-request helpers, structured
`tracing` events) that you'd otherwise have to assemble by hand.
It also exposes an [open-ended escape hatch](#design-the-escape-hatch) so any
endpoint not yet typed is a one-liner away.
## Quick start
`cirrus` runs on the [tokio](https://tokio.rs) runtime — internal retry/backoff
sleeps and the auth-cache synchronization both rely on it. Add tokio alongside
`cirrus` so you get a runtime entrypoint (`#[tokio::main]`) and a scheduler:
```toml
[dependencies]
cirrus = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
```
```rust,no_run
use cirrus::auth::StaticTokenAuth;
use cirrus::Cirrus;
use std::sync::Arc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let auth = Arc::new(StaticTokenAuth::new(
std::env::var("SF_ACCESS_TOKEN")?,
std::env::var("SF_INSTANCE_URL")?,
));
let sf = Cirrus::builder().auth(auth).build()?;
// SOQL
let result = sf.query("SELECT Id, Name FROM Account LIMIT 5").await?;
for record in result.records {
println!("{}", record);
}
Ok(())
}
```
## Features
### REST surface
- **sObject CRUD** — `sf.sobject("Account").{create, retrieve, update, delete, upsert}`
with typed and untyped variants. Multipart blob upload for `ContentVersion` /
`Document` / `Attachment`.
- **SOQL** — `sf.query(...)`, `sf.query_all(...)`, `sf.query_more(...)`. Typed
variants (`query_as::<T>(...)`) and a `futures::Stream` pagination iterator
(`query_stream(...)`) that walks `nextRecordsUrl` locators transparently.
- **SOSL** — `sf.search(...)` and `sf.parameterized_search(...)`.
- **Composite** — all four shapes: `composite/batch`, `composite/tree`,
`composite/sobjects` (collections), and the generic chained-reference
`/composite` endpoint.
- **Bulk API 2.0** — ingest (`bulk().ingest()`) and query (`bulk().query()`)
with the full lifecycle: `create`, `upload`, `close`, `abort`, `get`,
`results` (with `Sforce-Locator` cursor pagination), raw CSV downloads.
- **Tooling API** — `tooling().{describe_global, sobject(name).*, query, search,
execute_anonymous}`.
- **Apex REST** — thin passthrough for custom `/services/apexrest/...`
endpoints.
- **Event Monitoring** — `event_monitoring().{download, download_url}` for
binary `EventLogFile` CSV downloads.
- **Versions, limits, describe** — `sf.versions()`, `sf.limits()`,
`sf.sobjects().describe_global()`, `sf.sobject(name).describe()`.
### Authentication
Five OAuth 2.0 flows + static-token mode, all behind a common `AuthSession`
trait so handlers don't care which flow you used.
- **JWT Bearer** (RFC 7523) — `auth::JwtAuth::builder()`
- **Refresh Token** (RFC 6749 §6) — `auth::RefreshTokenAuth::builder()`
- **Client Credentials** (RFC 6749 §4.4) —
`auth::ClientCredentialsAuth::builder()`
- **Web Server with PKCE** (RFC 6749 §4.1 + RFC 7636) —
`auth::WebServerFlow::builder()`
- **Token Exchange** (RFC 8693) — `auth::TokenExchange::builder()`, including
hybrid grant
- **Static token** — `auth::StaticTokenAuth::new(token, instance_url)` for
paste-from-`sf-org-display` workflows
Auto-refresh on 401: when an expired token surfaces, `JwtAuth`,
`RefreshTokenAuth`, and `ClientCredentialsAuth` invalidate their cache and
retry once with a fresh token, transparently. Compare-and-swap semantics avoid
clobbering a token a concurrent task just refreshed.
### Cross-cutting
- **Retry + backoff** — `RetryPolicy` covers 429, 503, and transient 5xx with
full jitter; honors `Retry-After`. Configurable; off by default for non-idempotent 5xx.
- **Sforce-Limit-Info capture** — every response's API quota header is parsed
and surfaced via `sf.last_limit_info()`.
- **Pagination as `futures::Stream`** — composes with `StreamExt` from any
async ecosystem, so the consumer API surface isn't tied to a specific
combinator crate. Drop the stream → no further fetches. (Execution still
runs on tokio, like the rest of the crate.)
- **Conditional requests** — `describe_*_if_modified_since(SystemTime)` returns
`Option<T>`; `None` on 304 Not Modified.
- **Structured `tracing` events** — `cirrus::retry`, `cirrus::auth`,
`cirrus::limit_info` targets. Never logs tokens or bodies.
### The escape hatch
The Salesforce REST surface is too large to wrap every endpoint. The client
exposes verb methods that handle path resolution, auth, retry, and the
`Sforce-Limit-Info` capture for *any* endpoint:
```rust,ignore
sf.get::<MyShape>("limits").await?; // versioned
sf.post::<_, MyShape>("/services/apexrest/foo", &body).await?; // instance-rooted
sf.get::<MyShape>("https://...").await?; // fully-qualified
```
Three-mode path resolution: relative → `/services/data/{version}/...`,
leading-`/` → instance-rooted, `http(s)://` → passthrough.
For unusual cases (custom headers, binary download, SSE), `request_builder` and
`execute` give you a pre-authenticated `reqwest::RequestBuilder` and full bypass
respectively.
## Examples
See the [`examples/`](examples/) directory:
- `simple_query.rs` — connect, run a SOQL query, print results
- `crud_cycle.rs` — full create → retrieve → update → delete on an `Account`
- `bulk_ingest.rs` — Bulk API 2.0 CSV ingest
- `streaming_pagination.rs` — iterate all records via `query_stream`
- `apex_passthrough.rs` — call a custom Apex REST endpoint
Run an example after setting the required env vars (any sandbox / Developer
Edition / scratch org):
```bash
export SF_INSTANCE_URL=https://my-org.develop.my.salesforce.com
export SF_ACCESS_TOKEN=00D...!AQ... # from `sf org display`
cargo run --example simple_query
```
## Testing
```bash
cargo nextest run # default unit + property + wiremock tests (no network)
cargo clippy --all-targets # strict lints; deny set listed in Cargo.toml
cargo fmt # rustfmt
```
Integration tests against a real org are gated behind `#[ignore]` and an
opt-in env-var:
```bash
cp .env.example .env # fill in your org URL + token
cargo nextest run --test integration --run-ignored only -j 1
```
The integration harness refuses to run unless the configured instance URL
matches a known sandbox / Developer Edition / scratch My Domain pattern. See
`tests/integration/common.rs` for details.
## License
Licensed under the MIT license.