fizzy-sdk 0.3.0

Official client for Fizzy - https://fizzy.do
Documentation

Fizzy Rust SDK

The Rust client for the Fizzy API. Types, routes and service methods are generated from the Smithy model in the repository's spec/ directory, so what the crate offers is what Fizzy serves. Everything else — authentication, retries, pagination, the response cache, hooks — is the plumbing they share.

crates.io · docs.rs · repository

Install

cargo add fizzy-sdk tokio --features tokio/macros,tokio/rt-multi-thread

The client is async on Tokio, with rustls for TLS and no OpenSSL link. Requires Rust 1.88 or newer.

Feature Default What it adds
reqwest yes The shipped HTTP client. Off, supply your own HttpClient.
rustls yes TLS for the shipped client. The only backend; there is no native-tls.
tracing yes One tracing span per operation, carrying the status and request id.

Authenticate

Fizzy speaks two credentials and no OAuth. A bearer token, for scripts, CLIs and anything that already holds one:

use fizzy_sdk::{Client, Config, StaticTokenProvider};

# fn main() -> Result<(), fizzy_sdk::Error> {
let token = std::env::var("FIZZY_TOKEN").unwrap_or_default();
let client = Client::new(Config::default().with_env(), StaticTokenProvider::new(token))?;
# Ok(())
# }

A session token, for the mobile and web shells that signed in with a magic link:

use fizzy_sdk::{Client, Config};

# fn main() -> Result<(), fizzy_sdk::Error> {
let client = Client::builder(Config::default())
    .session_token(std::env::var("FIZZY_SESSION").unwrap_or_default())
    .build()?;
# Ok(())
# }

And the magic-link flow that produces one. create_session asks Fizzy to email a code and keeps the pending token the answer carries; redeem sends the code back with it. A process that finishes the flow later resumes it with MagicLinkFlow::resume and the pending token.

use fizzy_sdk::{Client, Config, MagicLinkFlow};

# async fn run(email: &str, code: &str) -> Result<(), fizzy_sdk::Error> {
let flow = MagicLinkFlow::new(Config::default())?;
flow.create_session(email).await?;
// ...the person reads the code out of the email...
let session = flow.redeem(code).await?;
let client = Client::builder(Config::default())
    .session_token(session.session_token)
    .build()?;
# Ok(())
# }

Config::with_env reads FIZZY_API_URL, FIZZY_ACCOUNT, FIZZY_CACHE_DIR and FIZZY_CACHE_ENABLED, the same variables the Go SDK reads. The token itself is the application's to keep: implement TokenProvider over your own store, or AuthStrategy to set the headers outright.

Use it

Most of Fizzy lives under an account. for_account scopes a client to one; the handful of routes outside an account — sessions, identity, access tokens, signup — hang off the client itself.

use fizzy_sdk::models::{CreateBoardRequestContent, CreateCardRequestContent};
use fizzy_sdk::{Client, Config, StaticTokenProvider};

# async fn run() -> Result<(), fizzy_sdk::Error> {
let client = Client::new(Config::default(), StaticTokenProvider::new("tok_..."))?;
let account = client.for_account("999")?;

let boards = account.boards().list().await?;   // a Page: derefs to the Vec it wraps
for board in boards.iter() {
    println!("{} ({})", board.name, board.id);
}

let board = account.boards().create(&CreateBoardRequestContent {
    name: "Launch".into(),
    ..Default::default()
}).await?;

account.cards().create(&CreateCardRequestContent {
    board_id: board.id.clone(),
    title: "Write the announcement".into(),
    ..Default::default()
}).await?;

let me = client.identity().get_my_identity().await?;
# Ok(())
# }

Services

One handle per resource: access_tokens, identity, sessions on the client; account, boards, cards, columns, comments, devices, identity, notifications, pins, reactions, search, steps, tags, uploads, users, webhooks on the account.

Every operation the model describes is generated, named for the operation with the service's noun dropped: ListBoards is boards().list(), ListClosedCards is boards().list_closed(board_id), MarkCardRead is cards().mark_read(card_number). Operation ids, methods, paths, retry policies and pagination styles are all public data in fizzy_sdk::routes; naming overrides live in rust/generator/names.toml.

Request bodies are plain structs that derive Default, built with ..Default::default(). Responses are #[non_exhaustive]: a field Fizzy adds later is not a breaking change.

Pages and streams

A paginated list operation — the thirteen the model marks Link-paginated, boards().list() among them — answers a Page<T>: the decoded body, plus the Link header Fizzy sent. A list the API serves whole (columns().list(), webhooks().list(), …) answers its Vec directly. Walk pages one at a time, or as a Stream of pages or items that reads lazily as it is polled (cargo add futures-util for the stream adapters).

use fizzy_sdk::{Client, Config, StaticTokenProvider};
use futures_util::TryStreamExt;

# async fn run() -> Result<(), fizzy_sdk::Error> {
let client = Client::new(Config::default(), StaticTokenProvider::new("tok_..."))?;
let account = client.for_account("999")?;

let first = account.boards().list().await?;
if let Some(second) = client.next_page(&first).await? {
    println!("{} more boards", second.len());
}

let mut cards = std::pin::pin!(client.items(account.boards().list_closed("b1").await?));
while let Some(card) = cards.try_next().await? {
    println!("{}", card.title);
}
# Ok(())
# }

A Link pointing off the configured origin is refused as a usage error rather than followed, and a walk stops at the client's page limit (10,000 by default). For a path the model does not cover, get_all reads every page as raw JSON.

Errors

Every call answers Result<_, fizzy_sdk::Error>. An error carries a code, a message, a hint when the server gave one, the HTTP status, the X-Request-Id, and whether a resend could have helped.

use fizzy_sdk::{Client, Config, ErrorCode, StaticTokenProvider};

# async fn run() -> Result<(), fizzy_sdk::Error> {
let client = Client::new(Config::default(), StaticTokenProvider::new("tok_..."))?;
match client.for_account("999")?.boards().get("nope").await {
    Ok(board) => println!("{}", board.name),
    Err(error) if error.code() == ErrorCode::NotFound => println!("no such board"),
    Err(error) => {
        eprintln!("{error} (status {:?}, request {:?})", error.http_status(), error.request_id());
        std::process::exit(error.exit_code());
    }
}
# Ok(())
# }

Codes: usage, not_found, auth_required, forbidden, rate_limit, network, api_error, validation, ambiguous — the same set, and the same CLI exit codes, as every other Fizzy SDK. A 401 is terminal: Fizzy has no refresh token to try.

Retries

Each operation carries its own retry policy from the model — how many attempts in total, the base delay, which statuses to resend on — and the client follows it on the first request and on every page after. Retry-After is honored, in seconds or as an HTTP date, up to max_retry_after. A POST is never resent unless the model marks the operation idempotent; a raw call can say so with RequestOptions::idempotent, or opt out with RequestOptions::no_retry. Retries back off exponentially with jitter. On the builder, max_attempts is a ceiling over every route's budget (3 by default, counting the first request), max_delay caps every backoff, a route's first one included (set it below base_delay and the first wait shrinks to it) — a Retry-After the server sends is honored as is, up to max_retry_after —, max_jitter bounds the randomness, and base_delay is the first backoff for raw calls, which have no route to read one from.

Hooks and tracing

Hooks is told when an operation starts and ends, when each attempt goes out and what it answered, and before each resend; on_operation_gate may refuse a call before it is sent. An attempt is one exchange as the SDK sees it: the redirect hops it follows inside are not reported one by one. Credentials stay out of it: a hook is handed the method, URL, attempt, status and timing, never the Authorization or Cookie header, and the crate's own logging redacts both. A URL can still carry something the application considers sensitive — a confirmation token in a path, say — so log the parts you mean to. NoopHooks does nothing and ChainHooks runs several in order.

use fizzy_sdk::observability::{Hooks, RequestInfo, RequestResult};
use fizzy_sdk::{Client, Config};

struct LogStatuses;

impl Hooks for LogStatuses {
    fn on_request_end(&self, info: &RequestInfo, result: &RequestResult<'_>) {
        println!("{} attempt {} -> {:?} in {:?}", info.method, info.attempt, result.status, result.duration);
    }
}

# fn main() -> Result<(), fizzy_sdk::Error> {
let client = Client::builder(Config::default())
    .access_token("tok_...")
    .hooks(LogStatuses)
    .build()?;
# Ok(())
# }

With the tracing feature (on by default) every operation runs inside a span named fizzy.operation, whose operation field names the call — filter on the field, not the span name — alongside the service, and the HTTP status and request id as they are known; each resend is a debug event on that span with the attempt number. Subscribe with any tracing subscriber; the SDK depends on none.

Caching

Reads can be cached by ETag: a repeat request sends If-None-Match, and a 304 answers from the cache without a body on the wire. Config::default().with_env().with_cache_enabled(true) turns it on with the file cache in the directory FIZZY_CACHE_DIR names — with_env is what reads the variable; without it the platform default directory is used, or Config::with_cache_dir sets one. ClientBuilder::cache takes any ResponseCache, and fizzy_sdk::cache::InMemoryCache is one.

Bring your own HTTP client

Everything the SDK sends goes through one HttpClient. The shipped one wraps reqwest; a shell that already has an HTTP stack implements the trait — one method, send, over the http crate's request and response types, with a streaming body — and hands it to ClientBuilder::http_client. Build with --no-default-features to leave reqwest out entirely. An implementation must not follow redirects: the SDK does that itself so credentials stay on the Fizzy origin.

Resilience

A circuit breaker, a bulkhead and a rate limiter are available in fizzy_sdk::resilience, configured through ResilienceConfig and installed as hooks. All three are off unless asked for. Response bodies are capped at 10 MB and error bodies at 10 KB, so a misbehaving server cannot exhaust memory.

Webhooks

Fizzy signs each delivery with HMAC-SHA256 over the raw body, hex-encoded, in the header fizzy_sdk::webhooks::SIGNATURE_HEADER. Verify it in constant time before trusting a payload:

use fizzy_sdk::webhooks::{compute_signature, verify_signature};

let secret = "whsec_...";
let payload = br#"{"event":"card.created"}"#;
let signature = compute_signature(payload, secret);
assert!(verify_signature(payload, &signature, secret));
assert!(!verify_signature(payload, &signature, "another secret"));

Develop

From the repository root:

make rs-generate      # regenerate src/generated and tests/generated_calls from openapi.json
make rs-check         # fmt, clippy, tests, docs, cargo deny, publish dry run
make rs-check-drift   # the generator's --check over both directories, plus the operationId census
make conformance-rust # the shared conformance fixtures, through this crate

rust/rust-toolchain.toml pins the toolchain the checks are run with. Generated code is never edited by hand; change the model in spec/ and regenerate every language.

Versioning

The crate follows the repository's release: every Fizzy SDK ships the same version from one tag. Before 1.0, a breaking change bumps the minor version and an additive one the patch, and cargo semver-checks runs in CI once a published baseline exists. The MSRV is 1.88 and is raised only when a dependency or feature needs it, as a minor release with a line in the release notes.

License

MIT. Copyright 37signals LLC.