hey-sdk 0.31.0

Rust client for the HEY API, generated from a Smithy model of the API
Documentation

HEY Rust SDK

The Rust client for the HEY 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 HEY serves.

[dependencies]
hey-sdk = "0.31"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

The crate is on crates.io and documented on docs.rs. To track the repository instead, depend on it at a release tag — the repository's vX.Y.Z tags are the crate's releases, and there is no rust/vX.Y.Z tag to look for:

hey-sdk = { git = "https://github.com/basecamp/hey-sdk", tag = "v0.31.0" }

Requires Rust 1.88 or newer; see Versioning.

The examples are this page's snippets as whole programs, and CI compiles them:

HEY_TOKEN=... cargo run --example first_call      # identity and boxes
HEY_TOKEN=... cargo run --example pagination      # page by page, and to the end
HEY_TOKEN=... cargo run --example hooks           # every operation, request and resend
cargo run --example oauth_pkce                    # the whole PKCE login, then a call
cargo run --example custom_http_client --no-default-features   # a transport of your own, offline

Authenticate

A fixed token, for scripts and anything that already holds one:

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

let client = Client::new(Config::default(), StaticTokenProvider::new(std::env::var("HEY_TOKEN")?))?;

OAuth 2.0 with PKCE, for user-facing apps: hey_sdk::oauth speaks the protocol — discovery, PKCE, the authorization URL, the code exchange and refresh. HEY wants an install_id on all three requests, a stable identifier the application mints once per installation, so every call takes one.

use hey_sdk::oauth::{self, ExchangeRequest, OAuthClient, ServerMetadata};

let oauth = OAuthClient::default();
let metadata = ServerMetadata::for_hey(&config.base_url);   // HEY publishes no well-known document
let pkce = oauth::generate_pkce();
let state = oauth::generate_state();
let url = oauth::authorization_url(&metadata, &config.oauth_client_id, redirect_uri, None, &state, &pkce, install_id)?;
// Send the person to `url`, receive the code on `redirect_uri`, then:
let token = oauth.exchange(&ExchangeRequest {
    token_endpoint: metadata.token_endpoint,
    code,
    redirect_uri: redirect_uri.to_string(),
    client_id: config.oauth_client_id.clone(),
    client_secret: None,
    code_verifier: pkce.verifier,
    install_id: install_id.to_string(),
}).await?;

Where the tokens live between runs, and when to refresh them, is the application's business: it implements TokenProvider over its own store and hands that to Client::new. Anything that wants the request headers outright implements AuthStrategy instead.

Use it

use hey_sdk::services::{BoxKind, MessageContent, ReplyContent};

let boxes = client.boxes().list().await?;   // a Page: derefs to the response it wraps
for mailbox in boxes.iter() {
    println!("{} ({})", mailbox.name, mailbox.kind);   // Imbox, The Feed, Paper Trail, ...
}
let imbox = client.boxes().get_imbox(&Default::default()).await?;
println!("{} postings", imbox.postings.unwrap_or_default().len());

// Sending: recipients are required. HEY saves an unaddressed message as a draft.
client.messages().send(&MessageContent {
    subject: "Subject".into(),
    content: "<div>Body</div>".into(),
    to: vec!["someone@example.com".into()],
    ..Default::default()
}).await?;

// Replying: start from the prefill. It carries the subject, the acting sender and the
// recipients HEY resolved, which differ from the account default on shared addresses.
let prefill = client.entries().new_reply(entry_id).await?;
client.entries().reply(entry_id, &ReplyContent {
    acting_sender_id: prefill.sender.as_ref().map(|sender| sender.id).unwrap_or_default(),
    subject: prefill.subject.clone().unwrap_or_default(),
    content: "<div>Reply</div>".into(),
    to: prefill
        .addressed
        .iter()
        .flat_map(|addressed| addressed.directly.iter().flatten())
        .filter_map(|contact| contact.email_address.as_ref())
        .map(|address| address.expose().to_string())
        .collect(),
    ..Default::default()
}).await?;

// Postings are bulk operations, as they are in HEY. Moving by kind resolves the box index
// once per client.
client.postings().mark_postings_seen(&[a, b]).await?;
client.postings().move_to_set_aside(&[a]).await?;
let trail = client.boxes().id_by_kind(BoxKind::PaperTrail).await?;

// Calendar
let track = client.time_tracks().start_tracking().await?;
let ongoing = client.time_tracks().get_ongoing().await?;   // Option: None when nothing is running

Services

One handle per resource, all off the client: attachments, boxes, bulk_replies, calendar_events, calendar_periods, calendar_todos, calendars, clearances, clips, collections, contacts, designations, entries, extenzions, folders, habits, identity, journal, messages, postings, publications, search, snippets, stickies, time_tracks, topics, workflows, world.

Every method the model describes is generated, and named for the operation with the service's noun dropped: ListBoxes is boxes().list(), GetBoxPostingChanges is postings().get_box_changes(..). Operation ids, methods and paths are all in hey_sdk::routes.

On top of those, src/services/*.rs are hand-written: they take the arguments a caller has rather than a request body, and cover the parts of HEY the model cannot describe. A hand-written method keeps the plain name where the generated service leaves it free, and takes the model's own name for the operation where it does not — postings().mark_postings_seen(&[a]) alongside the generated mark_seen(&body). One that changes the shape of the call rather than only its arguments may take a descriptive name instead: time_tracks().start_tracking() names the conflict a running track answers with, calendars().toggle_selection(..) answers the selection rather than the payload holding it.

Schema names are the model's own, with one exception: Box is hey_sdk::models::Mailbox, so it does not shadow std's. The rename is Rust's alone — a type name never goes on the wire — and lives in rust/generator/names.toml.

use hey_sdk::services::{BubbleUpSlot, ClearanceStatus, ContactParams, PostingChangesCursor};

// The Screener
for waiting in client.clearances().pending(None).await?.clearances.unwrap_or_default() {
    client.clearances().screen(waiting.id, ClearanceStatus::Approved, &Default::default()).await?;
}
client.contacts().screen(contact_id, ClearanceStatus::Denied).await?;
client.designations().create_box_designation(box_id, contact_id).await?;

// Contacts: update reads the contact first, since HEY's write is a full replacement
client.contacts().create_contact(&ContactParams {
    name: "Jane Dawson".into(),
    email_address: "jane@example.com".into(),
    ..Default::default()
}).await?;

// Bubble Up, and the incremental sync feed the mail clients follow
client.postings().schedule_postings_bubble_up(BubbleUpSlot::NextWeek, &[a]).await?;
let cursor = PostingChangesCursor::from_url(mailbox.posting_changes_url.as_deref().unwrap_or_default())?;
let changes = client.postings().all_changes(mailbox.id, &cursor).await?;
if changes.full_sync_required {
    // the cursor fell too far behind — read the box again
}

Form-backed writes

Workflows, collections, snippets, clips, publications, HEY World and the calendar writes have no JSON surface: HEY serves them only as browser forms that answer a redirect. The services cover them, so a caller does not have to know:

use hey_sdk::services::CreateCollectionParams;

client.workflows().create("Launch", None).await?;
client.workflows().stage_topic(topic_id, workflow_id, stage_id).await?;
client.collections().create(&CreateCollectionParams {
    name: "Launch".into(),
    ..Default::default()
}).await?;
let publication = client.publications().publish(topic_id).await?;   // .url is the public link
let token = client.world().publish("Subject", "<div>Body</div>").await?;

For a form endpoint nothing covers, Client::form builds the request — the path as written, a browser's Accept, the redirect captured rather than followed, and never retried — and Client::send_form sends it:

use hey_sdk::http::Method;
use hey_sdk::services::write_info;

let mut operation = client.form(Method::POST, "/workflows")?;
operation.info(write_info("Workflows", "CreateWorkflow", "workflow", None));
operation.form(&[("workflow[name]", "Launch")]);
let created = client.send_form(operation).await?;
let workflow_id = created.extract_id()?;         // out of the redirect's Location

The model describes none of these paths, so write_info is what the call tells the hooks it is; without it they only hear that something raw went out.

post_form, patch_form, delete_form and post_multipart are those two together for the common shapes. Unlike Go, a form failure keeps the code, hint and request id HEY answered with rather than being flattened to "Form request failed (HTTP 503)".

Beyond the model

client.request(method, path) builds an Operation for a path the model does not cover, with the same credentials, .json suffix, account scope and retry treatment. The raw verbs are that plus a send: get, get_html, get_csv, get_blob, download_blob, post, put, patch, delete, their _mutation variants for endpoints that answer something other than JSON, and get_all to walk a paginated path to its end. hey_sdk::url::router() recognizes pasted HEY URLs and names the operation and ids they refer to.

An Operation marked quiet skips the operation hooks while still firing the request ones, for a read-back made inside another operation — which is how publishing a thread reports one operation rather than two.

Pages

Reads HEY paginates answer a Page<T>. It derefs to the response, and carries the next cursor and X-Total-Count:

let mut page = client.contacts().list(&Default::default()).await?;
while let Some(next) = client.next_page(&page).await? {
    page = next;
}

next_page refuses a Link header that points off the HEY origin. each_page, get_all and follow_pagination walk up to the client's max_pages, and a walk that reaches it with pages still to read ends as a Usage error saying so rather than as a shorter list that looks complete. each_page has handed every page it read to the visitor by then; get_all and follow_pagination answer only the error, since a list cut short is the thing they exist not to hand back. Raise max_pages, or read with a limit.

Linked accounts

A root client presents mail from All Accounts. Derive one for a linked account to present that account's mail and act as its user and default sender:

let work = client.for_account(work_account_id).await?;
let postings = work.boxes().get_imbox(&Default::default()).await?;

for_account checks the account against the identity first, then adds HEY's filtered_account_id to every same-origin request. Calendar, journal, habits and time tracking belong to the identity and read the same through a scoped client.

Errors

Every call answers Result<_, hey_sdk::Error>. The error carries a stable ErrorCode (NotFound, Auth, Forbidden, RateLimit, Validation, Api, Usage, ...), the HTTP status, whether it is worth retrying, HEY's X-Request-Id, and a hint when the server or the SDK had one. It also keeps the body HEY answered the failure with, for the endpoints that describe a refusal there rather than in the status:

match client.contacts().create_contact(&params).await {
    Err(error) if error.http_status() == Some(409) => {
        let clash = error.body_json::<serde_json::Value>();
        // ContactConflict::from_error(&error) reads that same body, typed
    }
    other => { other?; }
}

Hooks

hooks on the builder reports every operation and every request the client makes: what the call means (Boxes.ListBoxes, the record it names, whether it changes anything), each attempt and how it turned out, and every resend before it is made. Every callback does nothing by default, so an implementation says only what it cares about, and several sets go on as one with ChainHooks:

use hey_sdk::observability::{Hooks, RequestInfo, RequestResult};

struct Log;

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

let client = Client::builder(Config::default()).token_provider(provider).hooks(Log).build()?;

The tracing feature, on by default, opens one tracing span per operation — hey.operation, with operation, service, and once HEY has answered http.status and request_id — and a hey.attempt child span per send, numbered the way the hooks number attempts, with the status each one got. Spans are put on the futures with Instrument, so concurrent calls keep their own, and a call the caller drops closes its span with no status. Nothing the caller passed is recorded: no path, no query, no body — a request for a path the caller wrote is named by its method alone, and the hooks are where its URL goes. A quiet send — a read-back inside another operation — opens no span of its own and runs in whichever span its caller is in. Any tracing-subscriber sees them; with default-features = false (plus reqwest if wanted) the crate depends on tracing for nothing and emits nothing. The hooks stay the place for a policy or a metric: they carry the whole RequestResult, and they run whether or not tracing is on.

on_operation_gate is the one callback that can refuse a call before it is sent, and the only one that may wait — which is how the bulkhead below holds a call back rather than turning it away.

Retries, refresh and caching

Every modelled operation carries its retry policy on its route (routes::LIST_BOXES.retry): max, the sends it gets in all; retry_on, the statuses that earn another; and the first wait between them. The client honours that policy on the first request and on every page next_page and each_page read after it, and only ever makes it gentler: the sends are min(max, max_retries + 1), base_delay is the least the client waits before the first resend, max_jitter is added to every wait, and max_delay is the most it waits between any two, jitter included — the one setting that can shorten the policy's own wait, and the way a test suite winds the backoff down. A positive Retry-After on a 429 the policy names is honoured as given, as a count of seconds or as an HTTP-date, above max_delay if need be. A route the model gives no policy is sent once, and so is any operation that is not idempotent, whatever its policy says. A path the caller wrote has no policy to bring, so an idempotent one runs on the client's settings alone and is resent on 429, 500, 502, 503 and 504; get_all and follow_pagination read every page that way. Any operation is resent once after a 401 that the token provider's refresh could answer, even with its sends spent — and once for all the calls a stale credential earned a 401 on: refreshes go one at a time, and a call signed before the last refresh is resent on the new credentials rather than refreshing again, so a rotating refresh token is spent once. With a ResponseCache (InMemoryCache, FileCache, or config.cache_enabled), JSON reads revalidate with If-None-Match and a 304 is answered from the cache. Response bodies are capped at max_response_body_bytes (16 MiB by default).

timeout on the builder is the HTTP client's: how long one request on the wire may take. operation_timeout is the operation's: how long the whole call may take, from waiting at the gate through credentials, every attempt, every wait between attempts, the resend after a refresh, and reading the body — everything the client waits for. Past it the call ends as a retryable network error and whatever it was doing is dropped — a bulkhead permit it held goes back and the hooks hear it end. Decoding the answer into your type comes after, on your own thread, and is not part of the wait. Neither bounds the other: without operation_timeout a call may take as long as its attempts and waits add up to.

Bring your own HTTP client

Everything the SDK sends — API calls, OAuth token requests, the attachment bytes that go to the storage service — goes out through one http::HttpClient. The reqwest feature, on by default, ships ReqwestClient over rustls and HTTP/2, and that is what Client::new and OAuthClient::default() use. To configure it, build it from reqwest's own builder:

use hey_sdk::http::ReqwestClient;

let http = ReqwestClient::from_builder(reqwest::Client::builder().proxy(proxy))?;
let client = Client::builder(config).token_provider(provider).http_client(http).build()?;

To replace it — a mobile shell on the platform's own stack, a test on canned answers — implement the trait. It is one method over the http crate's types, re-exported at hey_sdk::http:

use async_trait::async_trait;
use bytes::Bytes;
use hey_sdk::http::{Body, HttpClient, Request, Response};

struct PlatformHttp;

#[async_trait]
impl HttpClient for PlatformHttp {
    async fn send(&self, request: Request<Bytes>) -> Result<Response<Body>, hey_sdk::Error> {
        // hand the request to the platform, answer with its status, headers and a Body
        // built from the byte stream it gives back
    }
}

An implementation must not follow redirects: the SDK follows them itself, dropping the credentials on a hop off the HEY origin and giving up after ten, and a form request's redirect is its answer. Timeouts are the implementation's to enforce. With default-features = false there is no shipped client, and ClientBuilder::http_client is the only way to build a Client. The Go SDK takes an *http.Client instead; the trait is a deliberate divergence, so the SDK never forces a second HTTP stack into a binary that already has one.

Resilience

circuit_breaker, bulkhead and rate_limit on the builder — or resilience for all three — keep a struggling HEY from taking the caller down with it. Each keeps its counters per operation (Boxes.ListBoxes), so one failing operation does not shut down the reads beside it: the breaker gives up on an operation that keeps failing, the bulkhead caps how many calls of one kind run at once, and the limiter holds the client to a budget of its own and to any Retry-After HEY sends back. A refused call answers CircuitOpen, BulkheadFull or RateLimit without sending anything. Hooks installed before them still hear every operation.

Versioning

The crate follows Cargo's reading of semver before 1.0: a change that breaks the public API moves the minor version (0.290.30), an additive one moves the patch. CI runs cargo semver-checks on every pull request against the base branch, so an accidental break is caught before it is tagged; a deliberate one carries the breaking label and a version bump. What the API guarantees on the wire is the conformance suite's business, not semver's.

rust-version is 1.88, and CI builds the library on exactly that toolchain. It moves only when a dependency or a feature the crate needs requires it, and a move is a minor release with a line in the release notes, never a patch. Development and CI otherwise run on the exact stable that rust-toolchain.toml at the repository root pins, so rustfmt and clippy agree on every machine; a new stable arrives as a bump to that file.

How types evolve

Every public type is on one of two sides, and the side decides what a change to it costs.

  • Request-side types are built literally and are never #[non_exhaustive]. Request bodies (CreateMessageRequestContent), the *Params structs, Config, the resilience configs, ExchangeRequest and RefreshRequest: plain structs with public fields, and Default wherever every field has one, written as GetBoxParams { page: Some(2), ..Default::default() }. Adding a field to one is a breaking change — a literal without ..Default::default() stops compiling — and ships as 0.MINOR.
  • Response-side types and open enums are #[non_exhaustive]. Everything the SDK decodes and hands back (Mailbox, Posting, Token, the hooks' RequestInfo and RequestResult) and every enum whose variant set is HEY's to extend (ErrorCode, BoxKind, Pagination): read them, match them with a .. or _ arm, never build them. They keep Default, so Mailbox::default() still works where a test wants one. Adding a field or a variant is additive and ships as 0.x.PATCH — provided what HEY already sends still decodes: a new required field with no default, a DateTime say, would refuse yesterday's payload, and that is a break whatever the attribute says.
  • A type on both sides — sent in a body and read back, or handed to the client as well as read from it, like a Route a caller writes for a path the model lacks — is request-side.
  • Closed enums stay exhaustive. ClearanceStatus, OccurrenceScope and RepeatUntil name a choice the SDK defines, not a set HEY grows, so a match over them may stay exhaustive.
  • An open enum is #[non_exhaustive] whichever side it sits on: its declared variants are still there to build a request with, and only a match over it has to leave room.

The generator applies the same rule by reachability: a schema reachable from any request body is request-side, the rest are response-side. So a field the model adds to a response schema is a patch, and one it adds to a request schema is a minor.

When the model declares an enum, the generator emits a #[non_exhaustive] enum with one variant per declared value and Unknown(String) for any value HEY sends that the model did not declare, carried unchanged so a read-modify-write sends back what it read. Until the model declares one, the generator invents none: a kind is a String.

Develop

make rs-check               # every step CI runs before the drift check, in order: fmt, clippy,
                            #   tests, docs; clippy + tests again with --no-default-features;
                            #   examples, cargo deny, cargo package
make rs-check-drift         # fail if src/generated is stale; with rs-check, the whole CI job
make rs-generate            # regenerate src/generated from openapi.json
make conformance-rs         # run the cross-language conformance fixtures

make -C rust help lists the steps one by one. rs-check needs cargo-deny installed; the formatter's options are in rust/rustfmt.toml, stable ones only. Everything under src/generated/ is written by rust/generator; edit the generator or the Smithy model, never those files. Method and service names that the generator's rule gets wrong are settled in rust/generator/names.toml.