Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Capi for Rust

Write the endpoints. Ship everywhere.

Capi is a modular, type-safe framework for building API clients in Rust — REST first, with WebSocket, gRPC, GraphQL, HTML forms, and multipart/mixed batching on the same foundation. You define each endpoint once as a plain Rust type, and the framework delivers it across every HTTP backend, wire format, and execution model your users might need — synchronous or asynchronous, native or browser, JSON or XML or protobuf or a raw binary protocol.

The framework is deliberately unopinionated where it matters and deeply opinionated where it helps. Transport, wire format, and runtime belong to the application author and are exposed through swappable traits. Endpoint definition, the pipeline, authentication, rate limiting, and response decoding are built in, batteries included, so a client author can focus on the one thing only they can describe: the API itself.

The crates are at 0.5 and unpublished, on the way to a 1.0 whose public API is stable. Expect breaking changes before then.

Who this book is for

Two audiences, tagged throughout:

If you read nothing else first, read The Five Ideas and the Request Lifecycle. Everything else is an elaboration of it.

A recurring cast

A handful of production client crates appear as worked examples throughout, so the code you read has continuity rather than a fresh toy per chapter:

CrateWhat it proves
grok_capi_rsJSON, bearer + named-tier auth, SSE streaming, deferred polling, WebSocket, gRPC client streaming, multipart, pagination, resumable downloads
google_places_capi_rsAPI-key-in-header, OAuth2/JWT flows, typed header params (field masks)
aws_s3_capi_rsAWS SigV4 signing, presigned URLs, XML codec, raw and aws-chunked bodies, per-operation error enums
icecast_connect_capi_rsresumable/auto-reconnecting streams, a custom binary protocol, a codec-free client
ringcentral_capi_rsgeneric OAuth2 with a JWT-bearer grant as the default scheme
paychex_capi_rsa derived auth scheme that renews its own credential
azure_tables_capi_rsAzure SharedKey signing, OData batches and changesets
graphqlzero_capi_rsthe readable GraphQL reference

Design decisions are pointed out throughout — why things are the way they are, and where the escape hatches lie — so you can tell a hard requirement from a convenience with an opt-out.

The Decoupling Thesis

Building API clients in Rust is harder than it should be. The type system — the very thing that makes Rust reliable in production — turns the routine work of wiring up HTTP calls into a fight with the compiler. Async and blocking code don’t mix. Different HTTP clients don’t share types. Errors from one layer don’t compose with errors from another. What should be an afternoon’s work becomes an afternoon of yak-shaving.

The deeper problem is what that friction produces: a Rust ecosystem where most API clients lock their users into a single set of choices. Pick a library and you inherit its HTTP backend, its async runtime (or lack of one), its wire format, and its error model — whether or not they fit your application. An embedded developer shouldn’t be forced onto a server runtime. A wasm target shouldn’t be excluded because the client only speaks Tokio. A team standardized on one codec shouldn’t have to fork a crate. These are decisions that belong to the application author, not the library author.

The core idea

The insight at the heart of this framework — originally articulated by Ben Boeckel, whose proof-of-concept in rust-gitlab is the main inspiration — is that endpoints and clients do not need to be coupled.

Most API libraries tie the two together: a method on a client struct bakes in the URL, the parameters, the return type, and the transport all at once. That coupling is exactly what forces every downstream choice to cascade through the whole library. Decouple them, and three small, composable concepts fall out:

  • Endpoints are plain Rust structs implementing the Endpoint trait. Each declares its HTTP method, URL, headers, query, body, and response decoder — nothing about transport, nothing about runtime.
  • Interfaces pair a configuration (base URL, default headers, codecs, rate limiter, pipeline members, auth state) with an HTTP client. Built once, reused for every call to that API.
  • Queries execute an endpoint against an interface and hand back a typed result. query() is the single funnel; the endpoint’s type carries the method, body, output, and decoder — so there is no get()/post()/download() to keep in sync.

Three concepts, composable in every direction. The mental-model chapter develops them; this one is the why.

The pipeline

Everything the framework does sits between “you define an endpoint” and “you get a typed result”:

graph LR
    ep["Endpoint<br/><small>method, URL,<br/>body, decoder</small>"]
    req["ApiRequest<br/><small>built from<br/>endpoint + config</small>"]
    mw["The traversal<br/><small>members, gate,<br/>seal</small>"]
    cl["ApiClient<br/><small>reqwest, ureq,<br/>wasm-fetch, …</small>"]
    rsp["ApiResponse<br/><small>status, headers,<br/>body stream</small>"]
    dec["Decoder<br/><small>body, paged,<br/>stream, SSE</small>"]
    out["Output<br/><small>your typed<br/>Result</small>"]
    ep --> req --> mw --> cl --> rsp --> dec --> out

Each stage is a trait with default implementations and room for custom ones. The framework ships the glue; you ship the endpoints.

What you get

  • Endpoints as types, not methods. Define once, use everywhere.
  • Pluggable transport. Adapters over reqwest, ureq, the browser’s Fetch API, and reqwless for embedded no_std, plus the family’s own capic_native_client on its sans-io protocol engines — or implement ApiClient for your own.
  • Pluggable, typed wire codecs. JSON, URL-encoded, XML, and protobuf are selected as Codec<Format> handles the application swaps in — and the type tag makes a boundary that needs JSON reject an XML codec at compile time.
  • Sync, async, and the browser from one definition. The async feature switches the trait shapes, and the compilation target picks the platform — a browser build is --target wasm32-unknown-unknown with the same defaults (Features and Targets); endpoint code is identical across all of them.
  • A phase-ordered pipeline — Early / User / Mid / Late — for request IDs, cookies, redirects, tracing, and your own members, sealed by the credential placement no member can see; a transfer plane beneath it for throttling and progress on the bytes; and runners above it for retry, Retry-After, polling, and renewing a credential the service refused.
  • Authentication, batteries included: a keyed credential store with seated renewal, Bearer and Basic, OAuth2/OIDC with PKCE, JWT bearer grants, Google service accounts, and request signers for AWS SigV4, Azure SharedKey, and HTTP Digest — with presigned URLs and policies.
  • Rate limiting that respects the server: proactive GCRA and reactive header-driven limiting (X-RateLimit-*, Retry-After).
  • Advanced responses as pluggable decoders: SSE, document streams (JSON arrays, NDJSON, RFC 7464), chunked bodies, pagination, resumable binary downloads, multipart responses and batch envelopes, WebSocket, gRPC, GraphQL, and auto-reconnecting resumable streams — alongside ordinary typed JSON.
  • Content encodings as stream codecs: gzip, deflate, br, and AWS’s aws-chunked framing transform a body’s bytes on either side of the wire.
  • A typed send outcome. Timeout, stall, cancellation, and a refused budget are their own arms of SendError, distinct from the adapter’s failure, so a caller can tell what stopped a request.
  • Runs on no_std + alloc. Proven by the nostd_canary reference target, not just aspired to.
  • Less boilerplate: #[derive(ApiEndpoint)] and #[capi] cut the ceremony without hiding the types.

Honest tradeoffs

Capi is a foundation, not a ready-made SDK for one API, and it asks a few things in return:

  • Two platform camps, chosen by target. A build is native (with WASI) or the browser, and the browser lane is always async with relaxed Send/Sync bounds. No feature selects a platform, and the alignment guard fails a build whose crates disagree about the async or single-threaded axis — Features and Targets.
  • A trait surface to learn. The payoff of decoupling is more traits than a hardcoded SDK. This book’s build-order spine exists to make that surface approachable one slot at a time.
  • You still write the API. The framework handles transport, the pipeline, auth, and decoding; describing the endpoints and their types is the work that remains — which is the work only you can do.
  • Pre-1.0. The crates are at 0.5 and unpublished; expect breaking changes before the public API stabilizes.

The payoff is twofold. Client authors ship less code and maintain fewer variants, because one endpoint definition works across every backend. Application developers get clients that fit their stack instead of dictating it. That is the problem this framework solves, and the reason it exists.

Next: how Capi compares to the alternatives you might reach for instead, then a runnable client in one sitting.

Capi vs the Alternatives

Before committing to a framework it’s fair to ask what you’d do instead, and when the plainer option is the right one. This chapter positions Capi against the two things Rust developers actually reach for when they need to talk to an HTTP API.

Option 1: hand-roll a reqwest client

The default move is a struct wrapping reqwest::Client with one method per endpoint:

impl GitHubClient {
    pub async fn get_repo(&self, owner: &str, repo: &str) -> Result<Repo, MyError> {
        let url = format!("https://api.github.com/repos/{owner}/{repo}");
        let resp = self.http.get(&url).bearer_auth(&self.token).send().await?;
        if !resp.status().is_success() {
            return Err(/* map status + error body, somehow */);
        }
        Ok(resp.json().await?)
    }
    // ... one of these per endpoint, each re-deriving the same plumbing ...
}

This is fine for three endpoints. It stops being fine because every such client re-implements the same cross-cutting machinery from scratch — status-to-error mapping, retry, pagination, rate limiting, streaming — and hardcodes three choices into its very type:

  • reqwest, so the crate can’t serve a ureq (blocking) or wasm or embedded user without a parallel implementation.
  • tokio, so every consumer inherits an async runtime whether they want one or not.
  • serde_json, so an API that also speaks XML or form-encoding becomes a special case bolted onto the side.

Those are the application author’s decisions, baked in by the library author. A consumer who disagrees has to fork.

Option 2: a builder-style request crate

The other common shape is a crate that gives you an ergonomic request builder — you assemble method, URL, headers, and body fluently and call .send(). This removes some boilerplate, but the format, the client, and the runtime are still fixed by the builder, and the response side (typed errors, paged iterators, streaming, reconnection) is left to you. You’ve made the request nicer to write; you haven’t decoupled anything.

How Capi inverts it

Capi keeps the three hardcoded choices as swappable seams and moves the cross-cutting machinery into the framework. The endpoint becomes a value that describes the call and nothing else; the transport, the codec, and the runtime are chosen by the application at the interface. The decoupling thesis is the full argument; here is the practical comparison:

Hand-rolled reqwestBuilder-style crateCapi
Sync and async from one definition
wasm / embedded targetsrarely
Pluggable HTTP client
Pluggable, type-checked wire codec
Typed domain errors + framework-owned generic failuresyou write ityou write it
Streaming / pagination / WebSocket first-classyou write ityou write it
Downstream can patch an endpoint without forking
A credential store with seated renewal and request signers, shared by every endpointyou write ityou write it
A client that cannot perform an exchange is refused at compile time
Replay tests from recorded, redacted fixturesyou write ityou write it

The type-checked codec row is the one most alternatives can’t reach at all: because a codec is a Codec<Format> handle carrying a compile-time format tag, a boundary that requires JSON rejects an XML codec before the program runs — swappability without giving up safety.

When it’s overkill, and when it pays off

Reach for a hand-rolled reqwest client when the client is small, private, and will only ever run one way — a handful of endpoints, one format, async-on-tokio, no plans to ship it to anyone with different constraints. The abstraction isn’t worth it there, and this book will happily tell you so.

Reach for Capi when any of these is true:

  • You’re publishing a client others will consume, and can’t predict their runtime, client, or format.
  • You need one codebase to serve sync, async, and wasm (or embedded) targets.
  • The API has real response complexity — pagination, SSE/streaming, WebSocket, reconnection, deferred/long-poll — that you’d otherwise re-implement by hand.
  • You want consumers to patch a broken or missing endpoint without forking your crate.

The rest of this part gets you to a running client; if you’re weighing adoption, the design-rationale capstone lays out exactly which parts are hard requirements and which are conveniences you can opt out of.

Quick Start: A Runnable Client in One Sitting

This chapter goes from an empty project to a compiling, querying client in one file. It is deliberately the shortest path — one endpoint against the framework’s BaseConfig, no config or error type of your own — so the whole shape is visible at once. Scaffolding a Client Crate onward builds the same thing properly; here every corner that can be cut is cut.

The endpoint below is the same first request the capi_rs crate documentation and README teach, with a real transport in the client seat.

Prerequisites

  • Rust edition 2021, MSRV 1.88.
  • No serde knowledge: response types describe themselves through the framework’s own wire derives.

Step 1: Add dependencies

cargo new parcel_quickstart && cd parcel_quickstart
[dependencies]
capi_rs = { version = "0.5", features = ["async"] }
capi_wire_model = { version = "0.5", features = ["derive"] }
capic_reqwest = { version = "0.5", features = ["async"] }
capiw_serde_json = "0.5"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Four framework crates. capi_rs is the facade: it re-exports the Capi framework workspace and nothing else, so the wire model is a dependency of its own — the derives it exports generate code that names ::capi_wire_model directly. capic_reqwest is the transport, and capiw_serde_json the JSON codec. std is a default feature everywhere and needs no mention; async is the one feature this build turns on, and it is turned on for both crates that carry the axis, the facade and the transport. The compilation target — not a feature — picks the platform, which Features and Targets explains.

Step 2: Write the client

One file. It fetches a shipment from an imaginary parcel API.

use capi_rs::core::context::ApiContext;
use capi_rs::prelude::*;
use capi_wire_model::{WireDecode, WireModel};

// 1. The endpoint is a plain struct. `#[capi]` gives it a constructor
//    and setters; `ApiEndpoint` turns its fields into the request plan.
#[capi]
#[derive(Debug, Clone, ApiEndpoint)]
#[endpoint(path_template = "/v1/shipments/{shipment_id}")]
struct GetShipment {
    #[endpoint(location = "path")]
    pub shipment_id: String,
}

// 2. The response type describes itself to the wire model — no serde.
#[derive(Debug, WireModel, WireDecode)]
struct Shipment {
    id: String,
    status: String,
}

// 3. The Endpoint impl names the method, the URL, and the decoder.
impl Endpoint for GetShipment {
    type ApiConfig = BaseConfig;
    type Output = Shipment;
    type Error = CodecError;
    type Decoder = BodyDecoder;
    type Requires = Http;

    fn method(&self) -> Method {
        Method::GET
    }
    fn url(&self, config: &BaseConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path(self.path_string())
    }
    fn auth(&self) -> Auth {
        Auth::NONE
    }
}

// 4. `BodyDecoder` hands the response body to this one function.
impl DecodeBody for GetShipment {
    fn decode_body(
        body: Vec<u8>,
        _head: ResponseHead,
        _config: &BaseConfig,
        _context: ApiContext,
    ) -> Result<Shipment, ResponseError<CodecError>> {
        capiw_serde_json::codec().decode_from_slice(&body).map_err(Into::into)
    }
}

// 5. Pair a config with a client, then query.
#[tokio::main]
async fn main() -> Result<(), QueryError> {
    let config = BaseConfig::new(UrlBuilder::https("api.parcel.example"));
    let api = BaseInterface::new(config, capic_reqwest::ReqwestClient::default());

    let shipment = api.query(GetShipment::new("shp_123")).await?;
    println!("{}: {}", shipment.id, shipment.status);
    Ok(())
}

What just happened

Each numbered piece maps onto the pipeline:

  • The endpoint (GetShipment) is the letter: it states the method and URL and nothing about how the request travels. #[capi] generated GetShipment::new(shipment_id) — the field is neither Option, bool, nor Vec, so it is a constructor parameter — and ApiEndpoint generated path_string(), which fills the template from the field marked location = "path". type Requires = Http declares that the endpoint needs plain HTTP; a WebSocket endpoint would say WebSocket, and a client that cannot perform the exchange is refused at the query call site. headers, query_string, body, and the rest are provided methods this endpoint did not need to override.
  • auth() is overridden on purpose. The default is Auth::DEFAULT, a declaration that the request needs the config’s default credential, and BaseConfig installs no credential store — so the seal that places credentials would refuse the request before it is sent. Auth::NONE says the endpoint carries no credential. A real client’s config holds a store, and its endpoints leave the default in place; that is the authentication part of the book.
  • The decoder turns the response body back into a Shipment. BodyDecoder buffers the body and hands it to decode_body whatever the status; here a malformed body or a 404 both surface as a Decoding error, because nothing has told the framework what the service’s error bodies look like. A real client checks the status first and decodes an error envelope on the failure path — the status ladder — and the framework’s ResponseError carries the result either way.
  • decode_from_slice is the codec call. Naming capiw_serde_json::codec() inline keeps the example to one file; a real client stores a Codec<Json> on its own config and reads it back through config.json_lib(), so the JSON engine stays swappable — the config chapter.
  • BaseInterface::new(config, client) is where the two swappable choices meet: the config (base URL — and normally the codecs and the credential store) and the transport. Swap ReqwestClient for capic_ureq::UreqClient and the same endpoint runs blocking; the endpoint code does not change.
  • api.query(...) is the one funnel. It returns Result<Shipment, QueryError>; ? bubbles up any failure the framework owns — transport, decode, an unexpected status — as a QueryError.

Real endpoints add one derive to step 1: #[derive(WireModel, ApiEndpoint)]. WireModel is what lets #[wire(...)] attributes shape a field’s wire name and presence, and what gives the endpoint its redacted twin for fixtures and logs. The endpoint contract shows the full form.

What’s next

ChapterWhat you’ll learn
The Five Ideas and the Request LifecycleThe mental model the rest of the book assumes
The Wire ModelWhat Codec<Json> and the wire derives are
Scaffolding a Client CrateTurning this one file into a real crate with its own config
Consuming and Patching a ClientUsing — and fixing — a client someone else shipped

The Five Ideas and the Request Lifecycle

This is the chapter to internalize. Everything else in the book is an elaboration of five ideas and one lifecycle. Get these into your head once and the later chapters read as variations on a theme you already know.

Five ideas

  1. A client crate is a fixed set of files, each with one job. Config, auth, errors, decoders, types, endpoints — a Capi client always has the same skeleton, and scaffolding one is mostly filling in known slots. You are never guessing where something goes.
  2. Config is one service’s contract. A config describes a single API service — its base URL, default headers, one typed Codec<Format> per wire format, and the swappable auth state — and every endpoint is bound by type to the config it belongs to, so it can only be issued through the right service. Because the config holds everything identical across calls, an endpoint carries only what varies.
  3. An endpoint is a struct + two macros + one Endpoint impl + one line of decoder wiring. That’s the whole recipe. Once you’ve written one, every other endpoint is the same shape with different fields.
  4. One endpoint, multiple outputs ⇒ switch, don’t enum. When an operation can return different shapes, you don’t return a big enum the caller must match — you switch the output type, either on a request field (OverrideEndpoint) or on the server’s response (SwitchDecoder). The type system carries the choice.
  5. The framework owns every generic failure; you model one domain-error slot. IO errors, decode failures, unexpected statuses, unrecognized bodies — the framework already has variants for all of them. You describe only the one parsed error shape your API returns. This is the strongest single “why” in the framework, and it gets its own part.

Two of these — the typed codec (idea 2) and switch-not-enum (idea 4) — are things older SDK designs get wrong by coupling format and runtime into the endpoint. Here they’re separated on purpose, which is the thesis the introduction opens with.

The letter, the carrier, and the letterhead

The central insight is that endpoints and HTTP clients never need to know about each other. An endpoint describes what to request — method, URL, headers, query, body, and how to decode the reply. A client describes how to put bytes on the wire. Config bridges them with shared state.

The general form of that insight is the framework’s floor: a protocol’s description and a client that speaks it never need to know about each other, and HTTP is the one protocol with a request pipeline between them — because HTTP fixes framing and leaves meaning to each of millions of services, which is the gap endpoints, config, and the traversal exist to fill. A protocol carrying its own semantics needs no such layer, and its description rides the exchange directly (The Protocol Core and the Two Planes).

Mental model. An endpoint is a letter: it states what to say and where it goes. The client is the postal service: it knows how to deliver any letter but never reads one. The config is the letterhead and return address shared by every letter from one office. The letter doesn’t know how the mail is carried; the carrier doesn’t know what the letter says.

That separation is what makes clients, codecs, pipeline members, and auth pluggable without touching endpoint code — and it’s why the same endpoint definition can run sync, async, or on wasm.

Three layers

The framework arranges that separation into three layers, each talking to its neighbours through traits:

  • Endpoint layer — one struct per API operation, implementing Endpoint. Fully self-describing, so it’s trivial to test, compose, and reuse across clients.

  • Transport layer — turns endpoint + config into a Request (a request head plus a Bytestream body) and wraps the reply as an ApiResponse for decoding. ApiRequest::prepare() lives here; so does URL construction, header merging, and content-type negotiation.

  • Client layer — executes the call. ApiClient itself is only the identity of a client; the send lives on Speaks<Proto>, one impl per protocol a client speaks, carrying that protocol’s error type and its capi_send. For plain HTTP the send takes a Request and hands back a Response (on the async lane, a future of one):

    impl ApiClient for MyClient {}
    
    impl Speaks<Http> for MyClient {
        type Error = MyClientError;
    
        fn capi_send(
            &self,
            ctx: ApiContext,
            request: Request,
        ) -> Result<Response, SendError<Self::Error>>;
    }

    Those two impls are the whole of taking a seat: Supports<Http> — the bound the query funnel names, and the one an endpoint’s Requires is matched against — is derived from ApiClient + Speaks<Http> by a blanket impl, and nobody writes impl Supports. A client that cannot perform an exchange is refused at the query call site, at compile time — the capability plane.

    SendError is the framework’s own account of a send that produced no response, and it is what keeps the request budget readable through type erasure: four of its five arms — TimedOut, Stalled, Cancelled, Refused — belong to the framework, and only the fifth, Failed, holds the adapter’s own error. See the error model.

    The shipped adapters are of two kinds: transports over a third-party HTTP stack (capic_reqwest, capic_ureq, capic_wasm_fetch, capic_reqwless) that convert between Bytestream and the library’s own body type, and the family’s own capic_native_client, which drives the sans-io protocol engines over plain OS sockets with no third-party HTTP library in the exchange (see How the Crates Fit Together).

The core traits are the framework

The crates fall into two kinds: core trait crates (capi_core and capip_http, over the protocol-generic capi_protocol) that define contracts, and reference implementations (capi_base_config, capi_base_decoders, capi_base_interface, and the rest) that provide ready-to-use defaults. The reference implementations can be swapped, wholesale or in part, for anything satisfying the same trait bounds. So when you meet BaseConfig, BodyDecoder, or ReqwestClient, read them as defaults, not as the framework itself — the framework is the traits underneath. This is what makes the crate sprawl navigable and what makes patching imported endpoints work.

The request lifecycle: a trip down a funnel and back

Every call is a one-way trip down a funnel and back up.

Mental model. Going down, your typed endpoint is progressively flattened into raw bytes (struct → Request → wire). Going up, raw bytes are rebuilt into your typed output (wire → Response → struct). The request pipeline sits at the narrow waist, where everything is headers and bytes. Knowing which direction you’re in tells you which types you’re holding.

   your endpoint struct
          │  prepare()  ── reads the endpoint's declarative methods,
          ▼             then body(self) last
   Request  (request head + Bytestream body)
          │  the traversal (everything is headers and bytes here)
          ▼   Early → User → Mid → Late → gate → seal (resolution, placement, signing)
   transmit  ── the client's capi_send() for the lane, signer first, transfer stages on the bytes
          ▼
   Response  (response head + Bytestream body)
          │  unwind: the response halves of the same members, Late → Early
          ▼
          │  the endpoint's Decoder rebuilds your type, holding the door
          ▼
   typed Result<Output, QueryError>

Seven steps happen when you call interface.query(endpoint) — or endpoint.query(&interface), the same path spelled from the other end:

  1. You create the endpoint — a struct carrying this call’s data.
  2. ApiRequest::prepare() builds the request. It reads the endpoint’s declarative methods to assemble a Request — the URL, headers, Accept, and query string — then calls body(self) last, the one method that consumes the endpoint. The exact ordering, and the finer mechanical details, are traced in Under the Hood.
  3. The traversal runs — the request halves of the members of Early, User, Mid, and Late in order; then the rate-limit gate; then the seal, which resolves the credential — renewing a seated one that is near expiry — and places it. Nothing shapes the request after that.
  4. Transmit. The signer runs as the first act of every attempt, the transfer stages meter or throttle the bytes, and the client’s capi_send for the required capability performs the exchange. A response member may Resend the exchange it holds (that is how redirects are followed), resuming at its own seat.
  5. Unwind. The response halves of the same members run in reverse, Late back to Early.
  6. ApiResponse wraps the reply with a config snapshot, the door (a FollowUpChannel, through which pagination and streaming issue follow-up sends), and the ApiContext — everything the decoder needs.
  7. Decoding rebuilds your type. The endpoint’s declared Decoder, driven through ResponseDecoder, produces Result<Output, QueryError>; the status ladder inside a body decoder decides whether a status is an output or a modeled error.

Most of the time all seven happen behind one await. Knowing they’re there tells you exactly where to intervene: a pipeline member at the narrow waist, decoders on the way up, runners around the whole loop when you need to repeat or reshape it.

query() is the one funnel

There is no get(), post(), or download(). There is one entry point — query() — and the endpoint’s type carries the method, the body, the output, and the decoder:

let interface = BaseInterface::new(config, client);
let user = interface.query(GetUser { id: "123".into() }).await?;

Funnelling every call through a single method is a deliberate design decision — one of Capi’s oldest goals, in service of simplicity for the person on the other end. A developer picking up any client built on Capi learns exactly one verb: query. They never have to know, or care, whether a given endpoint is a GET, a POST, a PUT, or something more exotic — they reach for query, and the endpoint’s type supplies the rest. Rust’s trait system is what lets that one call carry the weight: from it the framework recovers the method and URL, the body encoding, the output type, the decoder, and which config it runs against — Endpoint::ApiConfig, which the interface’s own config must match, so an endpoint written for one service cannot be queried through another’s interface. And because the type carries everything, switching an output is just handing query() a different type — no parallel stream() / download() methods to keep in sync, and no enum to match at the call site.

On the name. query is the verb Ben Boeckel used in the article that seeded this framework, and it stuck. The HTTP spec has since grown an actual QUERY method, which makes the name look like it ought to sit beside a get() / post() family — but that resemblance is pure coincidence. query() is the funnel for every HTTP method, not a sibling of any of them.

With the five ideas and the funnel in place, the next two chapters fill in the supporting runway — how the crates fit together and the feature model — and then Scaffolding a Client Crate starts building a real client.

How the Crates Fit Together

Capi is spread across some eighty crates in seven workspaces and a ring of single-crate repos around them. That sounds like a lot to hold in your head, and the good news is you don’t have to: the framework proper arrives as one dependency (capi_rs) behind one glob (use capi_rs::prelude::*;), and a client names further crates only for the capabilities it actually uses. This chapter explains why the split exists, what the facade hides, and — the part that actually matters when you go reading source — which crates live inside the workspaces and which are siblings you swap out.

Why so many crates

At its core Capi is a trait system with a reference implementation. The idea behind Capi was to define a set of traits that tie together every element needed to build an API client library — endpoints, config, transport, decoding, auth — and to bind those elements to one another through strong types. Once the pieces connect through traits rather than concrete types, they behave like building blocks: you assemble the ones you need and the compiler checks that they fit. A handful of crates (capi_core foremost, with capip_http and capi_middleware, over the protocol-generic capi_protocol beneath them) define those contracts; everything else — the decoders, configs, transport, auth, interface, codecs — is one working implementation of them.

A consequence of that shape is that most pieces are replaceable: a config, a decoder, a limiter, a client adapter, a codec — each is an ordinary type bound to an ordinary trait, which is also why you can patch an imported endpoint. One piece is deliberately not: the traversal. capi_transport holds the engine that walks the pipeline, gates, seals, transmits, and unwinds — and the seal is exactly that, a seal. The door a decoder receives for follow-up sends carries a hidden marker trait, so an exchange composed anywhere else is not expressible: every exchange in the framework goes through the one traversal, which is what lets a fixture, a firewall, or a budget stand behind all of them at once.

Several practical forces turn that philosophy into crate boundaries:

  1. The orphan rule. Rust won’t let you implement a foreign trait on a foreign type. Each contract lives in its own crate so you can implement it for your types — you can impl Endpoint for GetUser only because Endpoint is in capi_core, not locked inside a third-party crate. The same rule is why a byte codec, a client adapter, or a concrete signer each gets its own crate: they implement framework traits for outside libraries (serde_json, reqwest, …).
  2. Looser version coupling. Keeping the reference implementations in separate crates from the core contracts means a change to a decoder, a limiter, or an adapter doesn’t force a version bump in capi_core. The contract crates are meant to change rarely; letting the implementation crates evolve on their own cadence keeps a wider range of versions compatible across the ecosystem instead of rippling a breaking-change wave through every dependent on each release.
  3. Granular dependencies. A no_std client can depend on capi_core plus one adapter and skip the rest, keeping build times and binary size down.
  4. The facade collapses it back. capi_rs re-exports the Capi framework workspace under stable module names and ships a large prelude, so the framework’s internal structure is invisible to the people who just want to call an API.

The facade and the prelude

Without the facade, imports are a scavenger hunt across crates:

use capi_core::endpoint::Endpoint;
use capi_core::config::ApiConfig;
use capi_base_decoders::BodyDecoder;
use capi_base_config::BaseConfig;
use capi_base_interface::BaseInterface;
// ... and a dozen more

With the facade it is one line:

use capi_rs::prelude::*;

The prelude pulls in several hundred items grouped by category — the core traits (Endpoint, ApiConfig, ResponseDecoder), the error taxonomy, the client and capability vocabulary, the request context and the send outcome, decoders and encoders, the interface, URL building, the pipeline vocabulary, rate limiting, authorization headers, authentication, config types, the HTTP method and status types, and the three alloc items the derives expect in scope (Cow, format, ToString). When you need a crate by name, the facade exposes each under a short alias (capi_rs::core, ::config, ::decoders, ::transport, ::extras, …), and passes the vocabulary a client’s own users need on through capi_rs::reexport for client libraries to forward downstream.

What the facade covers is the Capi framework workspace. The wire model, the format markers, and the capability crates outside it are named by the client that uses them, so its dependency list is an honest statement of what it builds on and each add-on’s lanes are forwarded from the client’s own features:

[dependencies]
capi_rs = "0.5"
capi_wire_model = { version = "0.5", features = ["derive"] }
capiw_ext_json = "0.5"      # the `Json` marker, for a `Codec<Json>`
capi_auth_oauth2 = "0.5"    # only when the client authenticates through OAuth2

Where the crates live

This is the distinction that will save you time in the source tree. Every repo is a sibling checkout under one directory; capi_test_suite/repos.toml is the registry that says what each is. There are three homes.

The capi_rs framework workspace holds the framework proper — the contracts and their in-tree reference implementations, thirty crates:

  • the protocol-generic core capi_protocol (Protocol, Speaks, ApiClient, Duplex, and the transport vocabulary in its carrier module) over its root, capi_context (the request context, the budget, the send outcome) — the floor everything else stands on;
  • the anchor capi_core — the HTTP core — and the facade capi_rs;
  • the request pipeline — capi_transport (the traversal engine and the seal), capi_middleware (the pipeline core: phases, members, verdicts), capip_http (the Http marker and the capability plane: Capability, Supports<Cap>, the envelopes, the follow-up channel), capi_authentication (the credential store, schemes, signers, presigning);
  • the runtime primitives capi_time, capi_lock, capi_bytestream, capi_notification, capi_feature_state, and the vocabulary crates capi_http_types (heads, headers, trailers, the canonical text format) and capi_url;
  • rate limiting: capi_rate_limit (the trait) and capi_limiters (the backends);
  • the base implementations capi_base_config, capi_base_decoders, capi_base_encoders, capi_base_interface;
  • capiw_wire_headers (the header plane’s encoder);
  • the add-ons capi_extras (runners, members, transfer stages) and capi_cookies (with its capi_cookies_macro);
  • the two client wrappers, capi_firewall (egress policy) and capi_debug_dump (development-time exchange dumps);
  • the macro crates capi_derive and capi_macro_support, and the dev-only test harness capi_test_core.

The other six workspaces each own one concern:

  • capi_wire — the wire model: capi_wire_model (the traits, Codec, CodecError, the Binding), capi_wire_derive, capi_wire_value (the runtime value tree), capi_wire_datetime, capi_wire_decimal.
  • capi_auth — the network auth flows capi_auth_jwt and capi_auth_oauth2.
  • capi_engine — the sans-io engines and the family’s own transport: the Conversation/Engine contract and the per-lane drivers in capi_engine (which re-exports the core’s carrier traits at the paths its engines use), the wire engines capip_http1 and capip_http2, the VOIP stack capip_sip, capi_sdp, capip_stun, capip_rtp, the two protocol crates capip_redis and capip_voip (each a marker beside the session its exchange yields), and capic_native_client, the engine-backed client. Everything here stands on capi_protocol, the core in the Capi framework workspace.
  • capi_graphql — the GraphQL runtime at the root, capi_graphql_macros (graphql_schema!, graphql!), and the host-only schema IR capi_graphql_schema.
  • capi_test_framework — replay testing, with capi_test_macros.
  • generic_llm_capi_rs — the OpenAI-compatible and Claude clients as one workspace.

Single-crate repos are the swappable, optional, and third-party-facing pieces — everything the orphan rule pushes outside, plus the capabilities a client opts into by name and the reference clients:

  • Format contracts (capiw_ext_*): capiw_ext_json (the Json marker), capiw_ext_urlencoded (UrlForm, and urlform_extension behind its off-default model feature), capiw_ext_xml, capiw_ext_protobuf.
  • Codecs (capiw_*): capiw_serde_json, capiw_urlencoded, capiw_quick_xml (std-locked), capiw_prost. Each carries its format’s conformance suite in its own tests/conformance.rs.
  • Transports (capic_*): capic_reqwest, capic_ureq, capic_wasm_fetch (browser only), capic_reqwless (embedded no_std).
  • Protocols and formats: capi_websocket (RFC 6455 framing, the WebSocket capability, correlated sessions), capi_grpc (unary, client-streaming, and server-streaming calls), capi_html_forms (the scrape-and-submit decoder), capi_http_batch (multipart/mixed batch envelopes and OData changesets).
  • Auth add-ons: the signers capi_auth_aws_sigv4, capi_auth_azure (Storage SharedKey), capi_auth_digest, and capi_auth_google (API key, service-account JWT, OAuth2 bridged onto Google’s endpoints).
  • Content encodings (capie_*): capie_gzip, capie_miniz_oxide (deflate), capie_brotli (br), capie_aws_chunked. Each ships StreamCodec implementations for Bytestream::with_codec; an algorithm never lives in the crate that negotiates it.
  • Middleware add-ons (capim_*): capim_x402, a payment runner — the prefix marks an add-on that plugs into the request pipeline, on either plane.
  • Clients (*_capi_rs): grok, google_places, google_routes, aws_s3, azure_tables, icecast_connect, ringcentral, paychex, graphqlzero, and the generic_llm workspace. Three private canaries prove a shape without being consumed: grpc_test (tonic interop), nostd_canary (bare-metal QEMU), runtime_canary (executor agnosticism).
  • Tooling: capi_verify — the capi_verify planner library and the cargo-capi binary, so cargo capi verify is the gate in any repo built on the framework; capi-extension-template, a cargo generate template for a new add-on; and the private capi_test_suite (the registry and the family sweep) and capiw_conformance_tests (the multi-codec properties).

Adapters are bridges, and the family also ships its own. The capic_* transports over reqwest, ureq, the browser’s Fetch API, and reqwless, and the capiw_* codecs over serde_json, quick-xml, and prost, are thin bridges from an outside library to the framework’s traits — the orphan rule is why they are separate crates. Any library can implement those traits itself, the way crates ship optional serde support. Beside the bridges sits capic_native_client, the family’s own transport on the capi_engine protocol engines, with no third-party HTTP library in the exchange.

The wire-model chapter leans on the contract-vs-codec split: a config depends on the tiny contract for the type (Codec<Json>) and on a concrete codec only to build one. That is the split in crate form — capiw_ext_json (the marker a signature names) vs. capiw_serde_json (the engine that turns the bytes, swappable).

A layered view

The crates stack into tiers; the async and single-threaded axes must agree along this stack (see Features and Targets), and a type’s tier tells you roughly where to look for it.

graph TD
    client["your client crate"] --> facade["capi_rs (facade + prelude)"]
    client --> caps["Protocols & flows — capi_websocket, capi_grpc, capi_graphql, capi_html_forms, capi_http_batch, capi_auth_oauth2, capi_auth_jwt"]
    client --> contracts["Format contracts — capiw_ext_json, capiw_ext_urlencoded, capiw_ext_xml, capiw_ext_protobuf"]

    facade --> interface["Interface — capi_base_interface, capi_extras"]
    facade --> config["Config — capi_base_config, capi_limiters"]
    facade --> decoders["Decoders/Encoders — capi_base_decoders, capi_base_encoders"]
    facade --> wrappers["Client wrappers — capi_firewall, capi_debug_dump"]
    facade --> cookies["Cookies — capi_cookies"]

    interface --> transport["Traversal & seal — capi_transport"]
    decoders --> transport
    transport --> core["Anchor — capi_core (Endpoint, ApiConfig, ResponseDecoder, errors)"]
    config --> core
    cookies --> core
    caps --> core

    core --> abstractions["Abstractions — capip_http, capi_middleware, capi_authentication, capi_rate_limit, capi_url"]
    core --> wire["Wire model — capi_wire_model (+ derive, value, datetime, decimal)"]
    contracts --> wire
    wrappers --> abstractions

    abstractions --> vocab["HTTP vocabulary — capi_http_types"]
    abstractions --> floor["Protocol core — capi_protocol (+ capi_context)"]
    vocab --> primitives["Primitives — capi_bytestream, capi_notification"]
    floor --> primitives
    primitives --> leaves["Leaves — capi_time, capi_lock, capi_feature_state"]

    codecs["Codecs — capiw_serde_json / urlencoded / quick_xml / prost"] --> contracts
    adapters["Transports — capic_reqwest / ureq / wasm_fetch / reqwless"] -.implements.-> abstractions
    native["capic_native_client"] --> engine["Sans-io engines — capi_engine, capip_http1, capip_http2, …"]
    native -.implements.-> abstractions
    engine --> floor

Three things to read off it. The facade’s fan-out is the capi_rs workspace; the protocol crates, the flows, and the format contracts hang off the client, which is why their lanes are forwarded from the client’s features rather than the facade’s. The dashed edges are the swap point: a transport implements ApiClient and Speaks<Http> — which seats it as Supports<Http> — just as a codec implements the wire model’s byte-engine trait; neither is on the mandatory path, and both are chosen by the config and the interface. And capi_http_types sits between the abstractions and the primitives: heads, headers, trailers, and the canonical text format are the vocabulary the pipeline, the authentication plane, and the recorder all speak. The exhaustive crate-by-crate table is in the appendix.

Naming conventions

The prefixes are a quick decoder ring:

PrefixRole
capi_rsThe public facade
capi_coreThe anchor: central traits + error taxonomy, re-exports sub-crates
capi_protocolThe protocol-generic core: Protocol, Speaks, ApiClient, Duplex
capi_*Framework crates (transport, the pipeline, url, context, carrier, …) and the protocol/format add-ons (capi_websocket, capi_grpc, capi_graphql, capi_html_forms, capi_http_batch)
capi_base_*In-tree reference implementations of the core abstractions
capi_auth_*Auth flows and concrete signers (capi_authentication is the plane they plug into)
capi_wire_*The wire model workspace (model, derive, value, datetime, decimal)
capiw_*Wire formats: capiw_ext_* are contracts, the rest are codecs — plus capiw_wire_headers, the header plane’s encoder
capic_*Client adapters: the four bridges and the engine-backed capic_native_client
capie_*Content-encoding stream codecs
capim_*Middleware add-ons that plug into the request pipeline
capip_*The protocol class: a sans-io engine, a marker implementing capi_protocol’s Protocol with what a client needs to speak it, or both
*_capi_rsClient libraries built on the framework

Which of those three a capip_ crate is varies: capip_http1 and capip_http2 are engines, capip_http is the Http marker and its capability plane, and capip_redis and capip_voip are both at once. The Protocol Core and the Two Planes is the next chapter, and it has the rule that decides where a marker lives.

The Protocol Core and the Two Planes

Everything so far has been HTTP’s: endpoints, a request pipeline, decoders, an interface. Underneath all of it sits something smaller that knows no wire at all — capi_protocol, the protocol-generic core — and beside HTTP stand the family’s other protocols, which use that same core and never meet a pipeline. This chapter is the floor: what the core names, what stands on it, where the message plane meets the transport plane, and the rules that keep one protocol’s vocabulary out of another’s.

It is a chapter for protocol, transport, and wrapper authors; writing endpoints against a service needs none of it. Reading it once is still worth the time, because it is what Requires = Http means when it says Http.

What the core names

Two traits and a marker, and they fit on one screen:

pub trait Protocol: MaybeSend + MaybeSync + 'static {
    type Outbound: MaybeSend;                  // what one exchange consumes
    type Inbound: MaybeSend;                   // what it produces
}

pub trait Speaks<Proto: Protocol> {
    type Error: core::error::Error + Send + Sync + 'static;

    fn capi_send(&self, ctx: ApiContext, outbound: Proto::Outbound)
        -> Result<Proto::Inbound, SendError<Self::Error>>;   // async: impl Future<..> + MaybeSend
}

pub trait ApiClient: Clone + MaybeSend + MaybeSync + 'static {}

A protocol is a marker binding two types and stopping there — no method, no configuration, no way to observe a message on its way past. Neither type is called Request or Response, because an exchange that dials, authenticates, and hands back a live session is as ordinary here as one that carries a single message, and the names have to fit both.

A client that can perform the exchange implements Speaks<Proto> for the marker: one impl per protocol, naming the failures this client reports for that protocol and carrying the send. The error belongs here rather than on the client because the failures do — a name that would not resolve, a handshake that failed, a pool that emptied, a background reactor that died — and no protocol vocabulary describes those. By convention a client reports every protocol it speaks through one error type; a consumer bounded on two protocols names the one it means, <Client as Speaks<Proto>>::Error.

ApiClient is the capability-free marker: a handle that can be cloned into a caller, held as long as the caller lives, and moved wherever the platform allows. No method, no error, no claim about any protocol. It is what a consumer bounds on when it wants a client to hold rather than a client to send through — an interface generic over its backend, a wrapper storing one, a builder taking one.

Beside the three sit the budget markers (TimeoutCapable, StallCapable, CancelCapable, and what each claims), the StdError bound, Duplex, and re-exports of the transport vocabulary as capi_protocol::carrier, the execution vocabulary as capi_protocol::context, and the platform bounds MaybeSend / MaybeSync. The Arc<Client> and Box<Client> pass-throughs live in the core as well — one Speaks impl each, covering every protocol at once — so no protocol crate ever mints a rival.

graph TD
    core["capi_protocol — Protocol, Speaks, ApiClient, Duplex, the budget markers, the carrier module"]
    context["capi_context — ApiContext, ContextId, CancelToken, SendError"]
    core --> context
    http["capip_http — the Http marker and the capability plane"] --> core
    framework["capi_core → capi_transport → capi_rs"] --> http
    engines["capi_engine → capip_http1 / capip_http2 / capip_sip / capip_stun / capip_rtp"] --> core
    protocols["capip_redis, capip_voip — markers and the sessions they yield"] --> core
    protocols --> engines
    adapters["capic_* clients"] --> core
    adapters --> http

The two crates at the top are the core: capi_context is its root, and capi_protocol is the crate with the contract in it and the transport vocabulary as its carrier module. Everything carrying a wire’s semantics stands above — the sans-io engines, the protocol crates they drive, the HTTP framework, and the clients that speak one protocol or several over a real transport.

The independent libraries underneath

Below the core sit ordinary Rust libraries that name no protocol and no client, and that a project can use without Capi at all: capi_bytestream (the body stream, and the MaybeSend / MaybeSync bounds), capi_lock (the mutex, cell, and Arc aliases), capi_time (the injectable clock), capi_notification (the typed broadcast a context carries), and capi_feature_state (the alignment guard). The dependency runs one way: the core reaches all five, and none of them reaches the core.

capi_wire_model is the sixth of that class and the one the core never touches. The wire model enters above, where HTTP’s bodies are — a protocol that defines its own commands has its own typed vocabulary and no use for a codec.

capi_feature_state is a special case worth naming: it holds no type and no trait. It exists only because the family’s use of the async and single-threaded axes is non-standard, and nothing that should outlive it may live there.

The two planes, and where they meet

There are two vocabularies at the floor, and they are deliberately separate.

The message plane is Protocol and Speaks: one exchange, typed at both ends, with no statement about how bytes get anywhere.

The transport plane is capi_protocol::carrier: the duplex byte boundary a protocol runs over. It divides the citizens by who waits and how the caller learns the carrier is ready — Carrier blocks, AsyncCarrier returns futures, PollDuplex never waits and never wakes, WakeCarrier never waits but does wake (which is why it is the erasure target). Dial and AsyncDial produce a connected carrier, and the datagram family draws the same division for message-boundary transports. Nothing in Carrier says “network”: a TCP stream, a TLS session, a pipe, a subprocess’s stdio, an in-memory channel, and another protocol’s session are all carriers.

The planes meet in exactly two places.

Duplex is the first, and it is the one type that belongs to both. It erases any carrier behind a single concrete type, so a protocol can declare a duplex as its Outbound (“run over this” — the client negotiates over the transport it was handed instead of dialing) or hand one back as its Inbound (session-as-transport — the session is a pipe another protocol can run over). Those two conventions are how layered protocols compose, and they are the reason no session trait exists at the floor: sessions stay bespoke types in their own crates. Duplex lives in capi_protocol because it needs both vocabularies plus the platform axis, which the core has and a root does not.

The engine-backed client is the second. capic_native_client’s impl Speaks<Redis> for NativeClient dials a carrier, hands capip_redis’s handshake to its engine, pumps the resulting conversation with an capi_engine driver, checks each negotiation reply, disarms the exchange budget, and hands the carrier over to the RedisSession. The client is the composition of carrier, engine, and protocol, and nothing else; the protocol knowledge stays in capip_redis and the I/O stays in the client. A transport over a third-party HTTP stack meets the carrier plane only if it hands out a transport at all.

Two platform styles at the foundation

The feature axes are not carried uniformly down here, and that is deliberate.

The Capi framework’s root crates carry the axis and name one shape per build. Duplex is one name whose definition is cfg-twinned: on the blocking lane its surface is Carrier’s write_all/read, and on the async lane (and browser wasm, where the async surface is target-implied) it is WakeCarrier’s waker-poll shape. capi_lock names its async locks only where the async lane exists. And capi_protocol and capi_context both carry assert_feature_alignment!("async", "single-threaded"), so a misaligned graph fails at compile time rather than at a trait bound.

capi_engine declares no cargo features and names every shape unconditionally: Carrier and AsyncCarrier both exist in every build, and the drivers are per-lane. A conversation is written once and every lane runs it.

The carrier vocabulary follows the engine’s style: every shape named, nothing gated on a feature or an axis. capi_engine stands on capi_protocol with its default features off and re-exports the traits from capi_protocol::carrier at the paths its engines use — an axis-indifferent crate over an axis-carrying one, the shape the manifest lint permits because the crates that declare an axis forward it to capi_protocol themselves. Feature choices made above the engines therefore reach the core directly, never through them.

Where a marker lives

The placement rule is one line: a protocol’s marker lives in the lowest crate that can name both its Outbound and its Inbound. Follow it and a contract author for one wire never acquires another wire’s types transitively.

MarkerCrateOutboundInbound
Httpcapip_httpRequestResponse
Grpccapip_httpRequestResponse
WebSocketcapi_websocketRequest(Response, Option<UpgradedConnection>), and the browser’s message-stream twin
Rediscapip_redisRedisConnectRedisSession
Sipcapip_voipSipConnectSipEndpoint
Mediacapip_voipMediaPlanMediaSession

WebSocket is the clearest case: its Inbound names the upgraded-connection type capi_websocket owns, so the marker can live nowhere lower. Redis names capip_redis’s own connect facts and session, and Sip and Media name capip_voip’s.

The same rule explains the capip_ prefix, which marks the protocol class rather than one crate shape. capip_http1 and capip_http2 are engines with no marker; capip_http is a marker and its capability plane with no engine; and capip_redis and capip_voip are both at once — each a marker beside the machinery that performs its exchange. What they share is the wire, not the shape.

And the seat is one impl per protocol per client: NativeClient writes Speaks<Http>, Speaks<Grpc>, Speaks<WebSocket>, Speaks<Redis>, Speaks<Sip>, and Speaks<Media> — each in its own module, all but the first behind the adapter feature that enables it, and every one of them naming NativeClientError as the error it reports.

No implication hierarchy

Capabilities are orthogonal. Providing Http says nothing about Grpc or WebSocket, and a gRPC-only adapter that speaks neither plain REST nor a socket upgrade declares only the marker it holds. That invariant’s home is the Http and Grpc doc blocks in capip_http, beside the markers it governs — it is a statement about the HTTP capability plane, where the temptation to arrange a hierarchy would arise.

The core has nothing to imply. Speaks<Redis> and Speaks<Http> are two impls of one trait, and there is no supertrait anywhere that would let holding either one drag the other in. The seat the pipeline bounds on, Supports<Cap>, is derived from ApiClient + Speaks<Cap> by a single blanket impl in capip_http; the whole mechanism is the capability plane.

Sessions for protocols, an interface for HTTP

A protocol whose exchange produces something live produces a session. RedisSession, SipEndpoint, and MediaSession are each built from connect facts, a client, and one exchange — and retain neither the facts nor the client afterwards. Each owns its carrier and keeps running after the exchange returns, which is why per-operation governance from there belongs to the session’s own API rather than to the exchange’s budget.

HTTP’s exchange produces no session, so its long-lived handle is an interface: BaseInterface is a config and a client and nothing else, connectionless by construction.

Stated as a rule: a protocol gets a session, HTTP gets an interface, and nothing is minted to unify the two shapes. They differ in what they are built from, in what they retain, and in when they connect; a common trait over them would have to erase all three to say anything at all.

Connect timing

The floor’s exchange connects when it runs. RedisConnect::to(..).open(&client, ctx) dials, sends the connect sequence, reads each reply, and hands back a negotiated session — all inside one capi_send. The context governs the dial and the negotiation, and is disarmed before the carrier goes to the session: an Inbound that owns a carrier outlives the exchange, so the exchange’s budget must not.

HTTP connects nowhere in particular. BaseInterface::new(config, client) opens no socket; every query hands the adapter a Request, and whether that means a new connection or a pooled one is the adapter’s business and invisible above it.

The four call sites

Four shapes cover everything the family does. The async lane is shown; the blocking lane is the same code without .await, because every capi_send is cfg-twinned at its definition.

// 1. A protocol with a session — connect facts, opened against a client,
//    then talked through. The dial and the negotiation happen at `open`.
let client = NativeClient::new();
let mut session = RedisConnect::to("cache.internal:6379")
    .auth(password)
    .open(&client, ApiContext::default())
    .await?;
session.set("k", "v").await?;
let hit: Option<Vec<u8>> = session.get("k").await?;
// 2. The HTTP pipeline — what every client crate in this book builds.
let api = ParcelApi::new_with_defaults(ReqwestClient::default());
api.authenticate("sk-live-…");
let shipment = api.query(GetShipment::new("shp_123")).await?;
// underneath: query → the traversal → transmit → Speaks<Http>::capi_send
// 3. Plain HTTP with no endpoint machinery — one message move.
let response: Response = client.capi_send(ApiContext::default(), request).await?;

// and its erased form, which the framework already ships:
let channel = FollowUpChannel::new(client);
let response = channel.capi_send(ctx, request).await?;
// 4. A socket session at the floor — same shape as 1, different protocol.
let mut endpoint = SipConnect::to("sip.example:5060", config, seed)
    .open(&client, ApiContext::default())
    .await?;
let expires = endpoint.register().await?;
match endpoint.next_event().await? {
    SipEvent::IncomingCall { call, .. } => {
        endpoint.answer(call, StatusCode::RINGING, None).await?;
    }
    _ => {}
}

A WebSocket is the interesting non-member of that list: it is a socket session, but it keeps riding query, because the upgrade is an HTTP request the seal, the middleware, the firewall, and the recorder must all see (WebSocket: Connections and Sessions).

Two asymmetries, documented rather than hidden

Put the first and second call sites side by side and two differences stand out. Both are real, and neither is worth papering over with a shared signature.

What the call takes. The floor’s open consumes the connect facts, borrows the client, and takes an explicit ApiContext. Each of those has a reason: the facts are consumed by the machine (a handshake holds the credentials, and the machine needs exactly one of them), Speaks has no Clone bound, and the context is the dial’s budget. The pipeline’s construction takes its config and its client by value, because an interface keeps both for its whole life.

Where the context comes from. At the floor the caller supplies one per exchange, and it governs that exchange and nothing else. In the pipeline nobody writes a context at a call site: ApiRequest::prepare derives one with config.base_context().prepare(), so every request gets its own ContextId and its own forked RNG without the caller naming either.

Both asymmetries follow from the exchanges being genuinely different — one produces a live session, the other a decoded value — which is exactly the difference the two shapes exist to carry.

With the floor in place, Features and Targets finishes the Foundations runway: which axes exist, what picks a platform, and how the lanes are tested.

Features and Targets

One endpoint definition compiles for a blocking desktop client, an async server, a browser tab, and a bare-metal board. Two mechanisms make that true, and they are deliberately kept apart: the compilation target picks the platform, and cargo features switch capabilities on. No feature selects a platform, and no feature set can lie about one — a build that misuses a target fails in the compiler, not at run time.

This chapter is the one place the book states that model. Every other chapter that touches platform — the manifest, the client adapters, the canaries — defers here.

The platform is the target

Capi splits platforms into two camps by target:

  • Native and WASI — every target that is not browser wasm. Crates that genuinely need a platform (transports, cryptography, HTML scraping) link the standard library here; the core pipeline does not, and stays no_std + alloc so it also runs on bare metal.
  • Browser wasmwasm32-unknown-unknown, the JS-hosted lane. Its canonical cfg predicate is all(target_arch = "wasm32", target_os = "unknown"). No crate links the standard library here. The async surface and the relaxed Send/Sync bounds are implied by the target itself.

WASI targets (wasm32-wasip1 and friends) are wasm, but they are not the browser: they have an operating system underneath, so they ride the first camp.

Building for either camp needs no feature flags:

cargo build                                    # native: std rides the defaults
cargo build --target wasm32-unknown-unknown    # browser: same defaults, other camp

No RUSTFLAGS either. The one host-specific requirement in the browser build — getrandom’s JS backend — is a plain feature the framework’s manifests enable from a browser-wasm target table (capi_context asks getrandom for wasm_js there), so the flag never reaches you. Bare-metal targets are the exception: with no entropy source to link, they register a custom backend with --cfg getrandom_backend="custom", which the nostd_canary keeps in its .cargo/config.toml.

The three axes

Three features propagate across the whole crate graph. Each is additive to the build; one of them is not additive to the API contract.

FeatureNative and WASIBrowser wasm
std (default)Links the standard library and switches on the family’s std surfaces: parking_lot locks, std::io, the native clock, filesystem-backed streamsInert as a standard-library switch — nothing links std. Acts as the platform-capability switch instead: each crate’s std includes its own js feature, which activates that crate’s JS host bindings (js-sys, wasm-bindgen, web-sys) from its browser-wasm target table
asyncSwitches the trait shapes from blocking to impl Future; the same definitions compile either wayAlways on — the surface is async regardless of the feature
single-threadedDrops the Send/Sync bounds graph-wide so !Send types flow through the frameworkImplied by the target; the feature changes nothing here

std is the only default. Disable it on a native target and you get the featureless core: the request pipeline on no_std + alloc, with no transport that can reach a network — that is what the bare-metal canary builds.

single-threaded is the one axis that is not additive to the API contract: turning it on removes Send for every crate in the build, so a native async runtime has to run on a current-thread configuration. It is graph-wide by construction — a build where some crates relax the bounds and others keep them would be a working binary whose strict crates silently keep bounds the lane meant to drop, which is exactly what the guard below refuses.

How the axes propagate

Every framework crate that declares async or single-threaded forwards it to capi_feature_state, so that crate’s feature set is the unified feature state of a build on those two axes. Cargo unifies features across the whole graph: if any crate turns async on, capi_feature_state’s async is on for everyone, and every crate compares its own local feature against that unified state at compile time.

std is deliberately not part of the unified state. It is additive within a target, so a graph that mixes std-on and std-off family edges still compiles and merely lacks some surfaces; you hunt a missing surface with the usual cargo tree -e features exercise, not with a guard.

The alignment guard

capi_feature_state::assert_feature_alignment! is invoked once at a crate root and lists the crate’s stance on each axis. It fails the build with a message naming the crate and the feature when the local flag and the unified state disagree. Its tokens:

TokenMeaning
"async", "single-threaded"Align: the crate declares or forwards this feature, and it must match the unified state
!"async", !"single-threaded"Forbid: the crate cannot participate in a build where any other crate turns this feature on
"native-only", "browser-only"Reach: the crate is pinned to one camp, and a build for the other target fails

For each axis a crate picks exactly one stance, or neither — a crate indifferent to both axes invokes no guard at all (the wire model, the codecs, the format contracts and capi_url carry none). There is no std token; std needs no guard.

The shipped invocations show the shapes:

// capi_core — dual-shape, and it declares both axes.
capi_feature_state::assert_feature_alignment!("async", "single-threaded");

// capic_reqwest and capic_native_client — native, dual-shape, and their
// worker-thread runtimes cannot give up Send.
capi_feature_state::assert_feature_alignment!("native-only", "async", !"single-threaded");

// capic_ureq — native, blocking only.
capi_feature_state::assert_feature_alignment!("native-only", "single-threaded", !"async");

// capic_wasm_fetch — the Fetch transport exists only in a browser.
capi_feature_state::assert_feature_alignment!("browser-only", "async", "single-threaded");

// capic_reqwless — embedded network stacks are single-threaded by nature.
capi_feature_state::assert_feature_alignment!("single-threaded");

A client library never writes the invocation itself. Every #[capi] and #[derive(ApiEndpoint)] expansion emits assert_feature_alignment!("async") through capi_macro_support, so a client whose async feature stops forwarding to the framework fails at its first derived endpoint, with the mismatch named.

A forbid token probes the crate’s own feature set with cfg, which trips the unexpected_cfgs lint when the crate (correctly) does not declare the forbidden feature. The crate silences it by telling check-cfg about the probed value:

[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("async"))'] }

When the guard fires, the enablers are found with the flags of the failing build:

cargo tree -e features -i capi_feature_state

Near the end of the output, the capi_feature_state feature "async" branch lists every crate whose own async is on. Restore alignment by enabling the feature on every crate the error names as missing it, or by removing it from the enablers.

single-threaded gets a second, run-time check. A mixed build on that axis compiles, so a test binary declares capi_feature_state::assert_lane_alignment!("single-threaded") in its integration-test root; it expands to a #[test] that fails when the crate’s own feature disagrees with the unified state. async needs no such probe — a mixed async build fails to compile outright, because the trait shapes differ.

MaybeSend and MaybeSync

The bounds the framework places on futures, streams, and trait objects are spelled MaybeSend and MaybeSync, not Send and Sync. Their definition is the whole mechanism:

#[cfg(not(any(feature = "single-threaded", all(target_arch = "wasm32", target_os = "unknown"))))]
pub trait MaybeSend: Send {}

#[cfg(any(feature = "single-threaded", all(target_arch = "wasm32", target_os = "unknown")))]
pub trait MaybeSend {}

Off browser wasm and without single-threaded, MaybeSend is Send, so a worker-thread runtime such as Tokio’s multi-thread scheduler can move the framework’s futures between threads. On browser wasm the target relaxes the bound by itself; single-threaded is the native opt-in for the same relaxation, which is why capic_reqwless requires it and capic_reqwest forbids it. Because the bound is graph-wide, a crate that only bounds on MaybeSend — most add-ons — declares no single-threaded feature and no token: the relaxation rides the markers. Only a crate that cfg-gates code of its own on the axis declares the feature and aligns it.

What a client library declares

A client forwards the axes into every dependency that has them and activates none of them itself:

[features]
default = ["default-codecs", "std"]
default-codecs = ["dep:capiw_serde_json", "dep:capiw_urlencoded"]
async = ["capi_rs/async", "capi_websocket?/async", "capic_wasm_fetch/async"]
std = ["capi_rs/std", "capi_wire_datetime/std", "thiserror/std", "capi_websocket?/std"]
websocket = ["dep:capi_websocket", "std"]

# Browser wasm ships the Fetch transport with the client; the target selects it.
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
capic_wasm_fetch = { version = "0.5" }

Three things to read off that block. std is a default, as it is everywhere in the family. The async line reaches the Fetch transport as well as the facade — capic_wasm_fetch declares its own async axis, and a browser build with --features async that did not forward it would fail at that crate’s guard. And the client declares no per-platform feature of any kind: the native transports are the consumer’s choice, and the browser one arrives with the target. The manifest chapter walks the whole file.

Feature sets and lanes

Features are additive per target, so --all-features is a valid build on either camp, and docs.rs renders the family’s crates with rich feature sets (all-features = true is the client convention). The one exclusion is single-threaded: it compiles everywhere but changes the documented bounds, so it stays out of docs.rs feature sets.

async cannot be forwarded into a dev-only test harness from a library feature, so the test lanes are named rather than inferred. cargo capi aliases writes them into a repo’s committed .cargo/config.toml:

cargo test-sync     # cargo test
cargo test-async    # cargo test --features async,capi_test_framework/async
cargo check-wasm    # cargo check --target wasm32-unknown-unknown --all-targets

The verifier runs those lanes — with the featureless build, the single-threaded lane where a crate declares it, and the doctests beside them — on every push; Verifying a Repo has the full lane table, the manifest lint, and the alias block’s rules.

That completes the Foundations runway. Scaffolding a Client Crate starts building a real client crate on exactly these rules.

Cargo.toml, Toolchain, and lib.rs

Foundations gave you the mental model; this part builds a real client crate, in the order you would build one. A Capi client is a fixed set of files, each with one job, so scaffolding is mostly laying down known slots. This chapter lays the foundation: the manifest, the verification policy it carries, the toolchain pin, the generated test-lane aliases, and the lib.rs that wires the modules together.

The running example is a hypothetical parcel_capi_rs. Every block below is the shape the family’s reference clients ship — google_places_capi_rs, grok_capi_rs, aws_s3_capi_rs — with the service’s names swapped out.

The manifest

[package]
name = "parcel_capi_rs"
version = "0.1.0"
edition = "2021"
rust-version = "1.88.0"
resolver = "3"
description = "Client for the Parcel API — shipments, rates, and tracking — built on Capi."
license = "MIT OR Apache-2.0"

[package.metadata.docs.rs]
all-features = true

# Verification policy the family's verifier reads (`cargo capi verify`).
[package.metadata.capi]

[features]
default = ["default-codecs", "std"]
## Batteries-included codecs: `capiw_serde_json` for JSON bodies and
## `capiw_urlencoded` for query strings, unlocking `ParcelApi::new_with_defaults`.
default-codecs = ["dep:capiw_serde_json", "dep:capiw_urlencoded"]
## Asynchronous APIs; the same endpoint definitions compile sync or async.
## Browser wasm always carries the async surface.
async = ["capi_rs/async", "capi_websocket?/async", "capic_wasm_fetch/async"]
## WebSocket endpoints (the live tracking feed).
websocket = ["dep:capi_websocket", "std"]
## Standard-library support on native/WASI; inert as a std switch on browser
## wasm.
std = ["capi_rs/std", "capi_wire_datetime/std", "thiserror/std", "capi_websocket?/std"]

[dependencies]
capi_rs = { version = "0.5", default-features = false, features = ["derive"] }
thiserror = { version = "2.0", default-features = false }

# Wire model + derives: the derive-generated code references `::capi_wire_model::…`.
capi_wire_model = { version = "0.5", default-features = false, features = ["derive"] }
capi_wire_datetime = { version = "0.5", default-features = false }
capiw_ext_json = { version = "0.5", default-features = false }        # the `Json` marker
capiw_ext_urlencoded = { version = "0.5", default-features = false }  # the `UrlForm` marker

# Capability crates the client opts into behind a feature of its own.
capi_websocket = { version = "0.5", default-features = false, optional = true }

# Optional batteries-included codecs, gated so a consumer can bring their own.
capiw_serde_json = { version = "0.5", default-features = false, optional = true }
capiw_urlencoded = { version = "0.5", default-features = false, optional = true }

# Browser wasm ships the Fetch transport with the client — the target selects
# it; no feature is involved.
[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
capic_wasm_fetch = { version = "0.5" }

[dev-dependencies]
# The replay harness rides its std default. Async-lane test runs select the
# lane on the command line (`cargo test-async`, below).
capi_test_framework = { version = "0.5" }

[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dev-dependencies]
# The recording lane is native-only: the family's own engine-backed
# transport, through which fixtures are captured.
capi_test_framework = { version = "0.5", features = ["native-client"] }

[lints.rust]
missing_docs = "warn"
unsafe_code = "deny"

[lints.rustdoc]
broken_intra_doc_links = "deny"

[lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "allow", priority = -1 }
nursery = { level = "allow", priority = -1 }

Reading it top to bottom:

rust-version is declared, and it is the framework’s floor. Every package in the family declares rust-version; the verifier’s manifest lint treats a missing one as an error with no allow form. 1.88.0 is the floor for capi_rs and the framework’s core crates, and the lowest a client built on them can declare. A client that leans on later Rust declares its own, higher floor — and pins its toolchain to match, below.

resolver = "3" makes Cargo’s version selection MSRV-aware, so a transitive dependency that needs a later compiler than the declared floor is passed over rather than pulled in. Edition 2021 defaults to resolver 2, so the opt-in is spelled out.

description is the crate’s opening sentence. The first //! line of lib.rs repeats it verbatim; the two are kept in lockstep so docs.rs, the registry, and the crate root say the same thing.

docs.rs renders all-features. Features are additive per target, so the richest set renders; the verifier’s docsrs lane builds the same set under -D warnings. The one feature never in a docs.rs set is single-threaded, which no client declares.

[package.metadata.capi] is the verification policy. The family’s verifier, cargo capi verify, reads how a repo verifies from the repo itself, in this table. An empty table is a complete policy: the default lanes apply. A client adds a key when its shape needs one:

[package.metadata.capi]
# A feature that pulls platform-bound crates (a std-locked codec, the
# jwt/oauth2 flows): the featureless lanes leave it off.
platform-std-features = ["auth-jwt", "auth-oauth2"]
# A second transport the async test lane must also switch on.
extra-async-test-features = ["capic_reqwest/grpc"]
# Extra aliases for the generated block (below).
aliases = { test-live = "test --test live_tests -- --ignored" }
# A client that cannot build for the browser at all.
browser = false

Every key is validated against the metadata it describes — an unknown key, or a feature or dependency name the package does not have, is an error.

[features] forwards, never activates. The three framework axes reach every dependency that declares them: std and async into the facade, the wire crates that carry them, thiserror, and any capability crate. Two details are load-bearing. The ? in capi_websocket?/async forwards the axis if the optional dependency is already in the build and does not pull it in otherwise, so --features async alone leaves the WebSocket code out while --features async,websocket builds it on the async lane. And async must also reach capic_wasm_fetch: the Fetch transport declares its own async axis and guards it, so a browser build with --features async that did not forward it fails at that crate’s guard. The verifier’s Rule F — the axes close over the whole family chain — has no allow form, because the resulting lane cannot compile. No feature selects a platform; the target does, per Features and Targets.

The facade is default-features = false plus derive. With defaults off, the client’s own std decides when the facade’s std is on, which is what makes the featureless build a real lane. derive brings the endpoint macros; add cookies when the client uses the cookie config layer, and auth-basic when it sends HTTP Basic.

The wire model and the format contracts are direct dependencies. The facade re-exports the Capi framework workspace and nothing else. The #[derive(WireModel)] output names ::capi_wire_model, so the crate must be resolvable under that name; Codec<Json> needs the marker crate that defines Json; the datetime carriers come from capi_wire_datetime. The same applies to every add-on the client drives — capi_auth_oauth2, capi_auth_jwt, capi_websocket, capi_html_forms, capi_grpc — each with its own feature forwarding.

The transport table. Native consumers pick their own adapter, so none appears in [dependencies]. The browser adapter is the one exception: capic_wasm_fetch ships with the client from a browser-wasm target table, because it is the only client a browser can run. A client that does not target the browser at all sets browser = false in its policy and omits the table.

Two dev-dependency tables. The replay harness is a plain dev-dependency; the recording lane — native-client, the family’s own engine-backed transport, or reqwest — lives in a dev-dependency table whose target excludes browser wasm, because it cannot compile there. The verifier’s Rule B requires that placement for any dev-dependency that cannot build for the browser, and Rule A forbids a library feature from forwarding into a dev-only dependency at all: Cargo would propagate the feature name without activating the optional dependency it gates, and the failure surfaces as an unresolved import far from its cause. That is why the async test lane is named as an alias rather than forwarded.

[lints]. unsafe_code = "deny" and missing_docs = "warn" are the family standard; broken_intra_doc_links = "deny" is what keeps the rustdoc honest when a type moves; the clippy groups set all to warn and leave pedantic and nursery off.

The codec dependency taxonomy

Recall the contract-vs-codec split: the format contracts a client needs for its types (capiw_ext_json’s Json, capiw_ext_urlencoded’s UrlForm) are unconditional dependencies — they carry marker types and the format’s attribute vocabulary, and there is nothing to gate. The codecs that turn bytes (capiw_serde_json, capiw_urlencoded, capiw_quick_xml, capiw_prost) are optional, behind default-codecs, so a consumer who wants a different engine is not made to compile yours. XML and protobuf add their own contract crates (capiw_ext_xml, capiw_ext_protobuf).

The gRPC lane is the one exception to the gate. A GrpcConfig must answer protobuf_codec() unconditionally, so a client with gRPC endpoints carries capiw_prost under its grpc feature rather than under default-codecs:

grpc = ["dep:capi_grpc", "dep:capiw_ext_protobuf", "dep:capiw_prost"]

capiw_quick_xml links the standard library, so a client whose default-codecs includes it lists that feature under platform-std-features, and the featureless lane leaves it off.

rust-toolchain.toml

[toolchain]
channel = "1.88.0"
components = ["rustfmt", "clippy"]
targets = ["wasm32-unknown-unknown"]

The pin and the manifest’s rust-version are one number: the verifier’s Rule J requires the toolchain file to pin exactly the highest rust-version the repo’s packages declare, so the declared floor is the compiler that builds them — a floating channel and a missing file are both violations. The targets line is the one that bites when omitted: without the wasm32 target installed, cargo check --target wasm32-unknown-unknown fails with a missing-target error that reads like a code bug. Pinning it here has rustup install it on first build.

.cargo/config.toml

The test lanes are named, not inferred, because async reshapes the traits graph-wide and cannot be forwarded into the dev-only harness from a library feature. cargo capi aliases writes them, and cargo capi aliases --check fails when the committed block has drifted from what the manifest’s metadata implies:

# >>> generated by `cargo capi aliases` — do not edit
[alias]
test-sync = "test"
test-async = "test --features async,capi_test_framework/async"
check-wasm = "check --target wasm32-unknown-unknown --all-targets"
# <<< end generated

Only the fenced block belongs to the generator; durable settings outside the fences survive regeneration. One thing never goes in this file: a [patch.crates-io] table. A patch entry names a filesystem path, and this file is committed. During development against unpublished family crates the table lives in a directory above every checkout, written by cargo capi patch — the consuming chapter covers it.

lib.rs

The crate root is no_std at its core — a client is mostly static data, and staying no_std keeps it usable on every lane:

//! Client for the Parcel API — shipments, rates, and tracking — built on
//! Capi.
#![no_std]
#![allow(missing_copy_implementations)]
extern crate alloc;
#[cfg(all(feature = "std", not(all(target_arch = "wasm32", target_os = "unknown"))))]
extern crate std;

pub mod auth;
mod config;
pub mod decoders;
pub mod endpoints;
pub mod errors;
mod interface;
pub mod types;

pub use capi_rs::reexport;
pub use config::*;
pub use interface::*;

Four conventions:

  • extern crate std is gated off browser wasm. Browser wasm never links the standard library; there, std is the platform-capability switch, not a library. The cfg says exactly that.
  • Reach for alloc, never std. Spell alloc::string::String, alloc::vec::Vec, alloc::boxed::Box, alloc::format in every module. The prelude (use capi_rs::prelude::*;) is the authoring surface for framework names; of the alloc types it re-exports only Cow, format, and ToString, so String and Vec are imported where they are used. A stray std:: path is a resolution error on the browser lane and in the featureless build.
  • config and interface are private modules, glob-re-exported. Users write parcel_capi_rs::ParcelConfig, not ::config::ParcelConfig, while the types stay exported. auth, decoders, endpoints, errors, and types are pub because their contents are the documented surface.
  • pub use capi_rs::reexport; passes the framework’s own re-export module through, so a consumer reaches framework types without adding capi_rs as a direct dependency.

Verifying the crate

cargo capi verify, run in the repo, is the gate: the manifest lint, clippy in the fixed lanes (default, featureless, std,async, and single-threaded where declared), the lane-matched test runs, the doctests, and the browser-wasm check. --full adds the feature powersets, the docs.rs renders, the fixtures gate, and formatting — Verifying a Repo is the reference for all of it. With the foundation in place, the next chapter fills the most important file: the config.

The Config: A Service’s Contract

The config is the most important file in a client. Think of it as the contract for one API service. A GrokConfig describes everything the Grok endpoints expect — the base URL they hang off, the default headers they carry, the codecs their bodies are written in, the auth state their calls need, a cookie jar if the service happens to sit behind Cloudflare. When you stand up a config you are standing one up for a service, and that service’s endpoints are bound to it by type. Because so much of that contract is identical for every call, the config doubles as the home for shared state — which is why an endpoint can carry only what varies. This chapter builds config.rs from the inside out.

Why endpoints are bound to a config

The Endpoint trait carries an ApiConfig associated type, and it has to match: a Grok endpoint declares type ApiConfig = GrokConfig, so it can only be issued through a Grok config — the endpoint literally needs that config to construct the request. That binding does two useful things beyond wiring.

It keeps endpoints from reaching the wrong service. The same Capi internals can back several client libraries in one binary, and structurally every endpoint is just an endpoint — nothing about the bare types stops google_client.query(grok_endpoint) from looking reasonable. The associated type is what stops it: an endpoint whose ApiConfig is GrokConfig will not go out through a Google interface, so a request meant for one service can’t be misdirected to another at the wrong URL.

And that same guard is a tool downstream users can pick up. Normally, when an API changes and a client library falls behind, you wait for the maintainer to publish a fix. Here you don’t have to. Because an endpoint is an ordinary type bound to a config, anyone can recreate a broken Grok endpoint with the correction and associate it with the original GrokConfig — and it drops straight into the existing client with the same auth, codecs, and pipeline as everything else. That single property is what makes Capi clients patchable in the field, and it’s the door to more advanced moves: overriding an endpoint’s decoding, or using IntoEndpoint to substitute your own input and output types for the built-in ones. Those get their own treatment later.

The config-vs-endpoint test

When you’re unsure whether something belongs on the config or the endpoint, ask: would this be identical for every call to this API? Base URL, default headers, the codecs, the auth state, the pipeline members, the default rate limiter — yes, those are config. A path parameter, a request body, a query value — no, those are the endpoint. The config holds the service-wide contract; the endpoint holds the one request.

The onion

Nothing in the framework requires you to build a config out of the pieces below. BaseConfig, CookieConfigExt, AuthConfigExt, and the rest are pre-built blocks — a config is anything that implements ApiConfig, and you could satisfy that trait by hand. What these blocks buy you is the boilerplate: BaseConfig already holds the base URL, the default headers, the rate limiter, the base context, the user agent, and the cosigner cell that almost every service needs, so instead of re-declaring all of that you wrap it and expose it through Deref. This is the building-block idea from the crate map applied to configuration — you assemble a new service client by picking the layers that match the service and letting each one handle only its own concern.

A config is built by wrapping BaseConfig in those capability layers, each an …ConfigExt<Inner>, and exposing the whole stack through Deref:

pub struct GrokConfig {
    config: AuthConfigExt<CookieConfigExt<BaseConfig>>,
    management_url: UrlBuilder,
    json: Codec<Json>,
    form: Codec<UrlForm>,
}

Read the type inside-out: BaseConfig (base URL, headers, rate limiter, base context) is wrapped by CookieConfigExt (a cookie jar; it needs the facade’s cookies feature) which is wrapped by AuthConfigExt (the credential store). Each layer adds a capability and forwards the rest. You reach the whole stack by implementing Deref/DerefMut to the onion:

impl Deref for GrokConfig {
    type Target = AuthConfigExt<CookieConfigExt<BaseConfig>>;
    fn deref(&self) -> &Self::Target { &self.config }
}
impl DerefMut for GrokConfig {
    fn deref_mut(&mut self) -> &mut Self::Target { &mut self.config }
}

That Deref is what gives your config base_url(), auth_store() (the store itself, whose set_auth/set_token write the credentials), cookie_jar() (behind the facade’s cookies-jar-access feature), and the rest without you re-declaring them. Each layer owns exactly one concern — CookieConfigExt carries the cookie jar and seats its member, AuthConfigExt the credential store — and forwards everything else down the chain. When the cookie jar is the credential, build both layers in one call: CookieConfigExt::new(base).session_auth("sessionid") returns exactly this shape, with a store that resolves a tier from that cookie’s presence — so endpoints behind the session keep Auth::DEFAULT rather than claiming they need no auth. Only the layers your client needs go in the onion; the codec-free icecast config below is just AuthConfigExt<BaseConfig>.

Because the layers are reached through Deref rather than a fixed shape, a consumer of a layer needs a way to find it in whatever onion you built. That’s what the small trait impls in the next section are for — they point the framework (and third-party crates) at the right layer. Most layers are conveniences you take or leave, but a few are effectively load-bearing: the auth layer in particular is something a number of other crates expect to be present and reachable, so if a service authenticates at all, exposing its auth store correctly (via the auth_store() override and the HasAuthStore impl, below) is not optional.

Typed codec handles

The codecs are stored as Codec<Format> fields and exposed through hand-written accessors — by convention json_lib(), form_lib(), xml_lib():

impl GrokConfig {
    pub fn json_lib(&self) -> Codec<Json>    { self.json }
    pub fn form_lib(&self) -> Codec<UrlForm> { self.form }
}

These are per-config, not methods on the core ApiConfig trait — a JSON-only client has no reason to answer xml_lib(). Endpoint and decoder code reads them at the point of use: config.json_lib().encode_body(...), config.json_lib().decode_from_slice(...).

The two constructors

Provide two ways to build the config — the swappable one and the convenient one:

impl GrokConfig {
    /// Batteries-included: the default codecs, behind the `default-codecs` feature.
    #[cfg(feature = "default-codecs")]
    pub fn new_with_defaults() -> Self {
        Self::new(capiw_serde_json::codec(), capiw_urlencoded::codec())
    }

    /// Explicit codecs — the swap point for a consumer who wants a different engine.
    pub fn new(json_lib: Codec<Json>, form_urlencoded_lib: Codec<UrlForm>) -> Self {
        let config = BaseConfig::new(DEFAULT_BASE_URL).with_user_agent(DEFAULT_USER_AGENT);
        Self {
            config: AuthConfigExt::bearer(CookieConfigExt::new(config)),
            management_url: MANAGEMENT_BASE_URL,
            json: json_lib,
            form: form_urlencoded_lib,
        }
    }
}

Note BaseConfig::new takes a UrlBuilder, not a &str — build the base URL with UrlBuilder::https("api.x.ai") (usually a const), then chain builders like .with_user_agent(...). new_with_defaults() exists only when default-codecs is on; new(...) is always available and is what makes the JSON engine swappable.

The required trait impls

Two traits make the config usable, both just forwarding into the onion; a cookie-login config adds a third, HasCookieJar, which the AuthConfigExt<CookieConfigExt<_>> shape satisfies through a blanket impl and a hand-rolled outer config forwards in one line.

ApiConfig is the core contract the framework reads. base_context returns the template every request context is derived from — configure its clock, RNG and notifications here and each request inherits them:

impl ApiConfig for GrokConfig {
    fn base_context(&self) -> ApiContext { self.config.base_context() }
    fn headers(&self) -> HeaderMap { self.config.headers() }
    fn middleware(&self) -> Pipeline { self.config.middleware() }
    fn default_rate_limiter(&self) -> impl ToRateLimiter { self.config.default_rate_limiter() }
    fn cosigner(&self) -> impl ToCosigner { self.config.cosigner() }
    fn user_agent(&self) -> Option<HeaderValue> { self.config.user_agent() }
}

base_context and user_agent are required; headers, middleware, default_rate_limiter, and cosigner have defaults, and a wrapper forwards them anyway so the inner layers’ members and settings apply. cosigner() is the one whose omission is silent: ApiConfig defaults it to NO_COSIGNING, while BaseConfig answers it from its cosigner cell — so a wrapper that leaves the default in place turns config.set_cosigner(..) into a no-op, with nothing to say so.

ApiConfig::auth_store exposes the credential store to the seal, which places the credentials it holds; it is optional because not every API authenticates. A config that does authenticate also implements HasAuthStore with the same store — the capability the stored-credential schemes bound on, and the reason .authenticate(...) cannot fail:

impl ApiConfig for GrokConfig {
    // …
    fn auth_store(&self) -> Option<AuthStore> { Some(self.config.auth_store()) }
}

impl HasAuthStore for GrokConfig {
    fn credential_store(&self) -> AuthStore { self.config.auth_store() }
}

Defaults: the pipeline and rate limiting

middleware() and default_rate_limiter() usually forward straight to the onion, but this is where you set client-wide defaults. A config that wants a correlation id on every request seats one member on the forwarded pipeline:

fn middleware(&self) -> Pipeline {
    self.inner
        .middleware()
        .with_request(Phase::Early, InjectRequestId::new("x-request-id"))
}

The phase decides when the member runs relative to the others; Middleware covers the four phases and the three member traits; InjectRequestId lives at capi_rs::extras::middleware::InjectRequestId, outside the prelude. For a default rate limit, override default_rate_limiter() (note the name — it’s with_default_rate_limit / default_rate_limiter, there is no with_rate_limiter); see Rate Limiting.

Capability traits a config opts into

HasAuthStore is one instance of a pattern: a service opts into a capability by implementing a : ApiConfig trait on its config, and the endpoints that need the capability bound on it. The shipped ones:

TraitCrateWhat the config supplies
HasAuthStorecapi_corethe credential store the stored-credential schemes write
HasCookieJarcapi_cookiesthe jar a cookie-login flow reads the session from
GrpcConfigcapi_grpcprotobuf_codec(), and optional max_message_size, compression, grpc_headers
BatchConfig / ChangesetConfigcapi_http_batchthe service’s batch endpoint URL, from the config’s own host knowledge

A gRPC client stores a Codec<Protobuf> under its grpc feature and answers protobuf_codec() from it; an OData client answers ChangesetConfig with its $batch URL. Each trait is the whole contract between the add-on and the service’s config.

Worked variants

The same skeleton absorbs every auth and format shape. Four contrasts:

Codec-free (icecast). A client speaking a binary protocol stores no codecs at all — the onion is just AuthConfigExt<BaseConfig>, there are no json_lib() accessors, and new() takes no codec arguments. Proof that the wire model is opt-out.

Multi-format + signing (aws_s3). Stores three codecs, the credential store, and the credential-free signer:

pub struct AmazonS3Config {
    config: BaseConfig,
    form: Codec<UrlForm>,
    xml: Codec<Xml>,
    json: Codec<Json>,
    auth: AuthStore,
    sigv4: SharedCell<Option<Sigv4Signer>>,
    // ...
}
// new_with_defaults() wires capiw_quick_xml + capiw_urlencoded + capiw_serde_json;
// new(xml_lib, form_lib, json_lib) takes all three explicitly.

and publishes a snapshot of it for its endpoints to declare:

pub fn service_signing(&self) -> Signing {
    self.sigv4.with(|s| s.clone().map(Signing::new).unwrap_or(NO_SIGNING))
}

The service scheme is typed config state, not an ApiConfig method — each signed endpoint returns config.service_signing() from Endpoint::signer, so whether a request is signed is readable off the endpoint. The signer itself is installed at runtime by the API’s authenticate(...) — see Request Signers and Presigning.

API-key-in-custom-header (google_places). Instead of an Authorization header, the key rides in a vendor header. AuthConfigExt::new_with_auth_header sets that up:

config: capi_auth_google::GoogleAuthConfigExt::new(
    AuthConfigExt::new_with_auth_header(config, HeaderName::from_static("x-goog-api-key")),
    json_lib,
    form_urlencoded_lib,
),

Here the onion has a vendor layer too (GoogleAuthConfigExt, from the dedicated capi_auth_google crate), wrapping the standard AuthConfigExt.

Canonical JSON (grok). The GrokConfig shown throughout this chapter — a JSON body codec, a urlencoded query codec, bearer auth, cookies. This is the shape most clients start from.

Base URL as a parameter (generic_openai). A client for an API served by many providers takes the base URL in its constructor instead of a constant:

pub fn new(base_url: UrlBuilder, json_lib: Codec<Json>) -> Self {
    let config = BaseConfig::new(base_url).with_user_agent(DEFAULT_USER_AGENT);
    // ...
}

with new_with_defaults(base_url) beside it under default-codecs.

Every one of these is the same three moves: a struct wrapping an onion plus typed codec fields, two constructors, and the ApiConfig impl (with cosigner() forwarded) plus its auth_store() override and HasAuthStore beside it. With the config standing, Authentication wires up authentication.

The Auth Model: Credentials vs Usage

Authentication in Capi rests on one separation: getting a credential and using one are different concerns. An endpoint declares that it needs a credential; a credential store holds the actual secret; and the traversal’s seal places the stored secret into each request that asked for it. Keep those three apart and every auth strategy in the next two chapters is a variation on the same machine.

The two halves

  • Usage is declarative and lives on the endpoint. An endpoint’s auth() says which credential slot this request draws from — nothing about what the secret is.
  • Credentials are runtime state and live on the config, in an AuthStore. You put a secret in once (api.authenticate("…")); it stays there and rides every request that declared the matching tier.

Because they’re separate, the same endpoint definition works before and after you authenticate, works with a key from an env var or a vault, and can target a second credential tier without any change to its body or decoder.

AuthStore, keyed by AuthKey

The store lives in capi_authentication and is re-exported at capi_core::authorization and in the prelude. It is a small, Arc-backed, cloneable map from an AuthKey to a credential:

pub enum AuthKey {
    Default,          // the primary credential
    Named(Arc<str>),  // a second (or third) tier, e.g. "management"
}

You rarely touch AuthStore directly — the scheme’s authenticate(...) does it for you — but the write underneath the Token scheme is:

store.set_token(AuthKey::Default, api_key);   // dressed with the store's default placement
store.set_token("management", other_key);     // `impl Into<AuthKey>`: &str → Named

set_token stores a bare token dressed with the placement the config declared once — written into the store’s default_header_name behind its default_prefix, which AuthStore::new().with_default_prefix("Bearer ") and .with_default_header_name(..) set, and which AuthConfigExt::bearer and with_placement build for you. The token handed in later needs no knowledge of the placement. The undressed write is set_auth(key, header_value), which stores the value as the whole header; both accept impl Into<AuthKey>, so a &str, String, or Arc<str> becomes a Named tier automatically. The stored token is marked sensitive on insertion, so it’s redacted in logs and Debug output. Lookups use entry(&key) / contains(&key) — there’s no get — and remove_auth(&key) / clear() are logout: they drop the entry and any renewal seat with it.

A scheme prefix is stored beside its token, not inside it

A credential written into Authorization usually carries a scheme name in front of it — Bearer <token>. The store keeps those two apart:

store.set_auth_entry(
    AuthKey::Default,
    AuthEntry::prefixed(AUTHORIZATION, "Bearer ", token),
);

The prefix is joined to the token verbatim when the credential is injected, so it owns its own separator: "Bearer " includes the space, "xai-client-secret." includes the dot. Keeping them apart is what lets an endpoint present the same secret somewhere else under a different dressing (below), and it sharpens redaction — the scheme stays readable in Debug output while the token is replaced.

A credential can occupy more than one place

What the store holds under a key is an AuthEntry: an ordered set of placements, each one an AuthPart. set_auth builds the common case — a single header, the store’s default_header_name — and set_auth_with_header picks a different header for that one entry. Services that split a credential across several headers, that take an API key as a query parameter, or that expect it as a WebSocket subprotocol entry build the entry themselves and store it with set_auth_entry:

// An app id beside an app key, both required on every call.
store.set_auth_entry(
    AuthKey::Default,
    AuthEntry::new(HeaderName::from_static("x-app-id"), app_id)
        .and_header(HeaderName::from_static("x-app-key"), app_key),
);

// An API key the service wants in the query string.
store.set_auth_entry(AuthKey::Default, AuthEntry::query_param("key", api_key));

The seal applies every part of the matched entry: headers are inserted with their prefix and token joined, and query parts are percent-encoded and appended to whatever query the request already carries. Every token is sensitive regardless of where it lands — but a query credential ends up inside the URL, which proxies and access logs record verbatim, so prefer a header whenever the service accepts one.

An entry is stored and replaced as a whole, so a re-minted multi-part credential is never observed half-applied: a request either carries all of the old credential or all of the new one.

What else an entry can be

Placements are the common case, not the whole of it. An AuthEntry can also:

  • Be a presence. AuthEntry::presence() has no parts at all: it says the credential exists, and another mechanism presents it — the session cookie a jar writes at the Late seat. A required declaration resolves against it and the seal writes nothing, which lets an endpoint riding such a credential declare its tier honestly instead of Auth::NONE.
  • Expire, and be renewed. AuthEntry::minted(token, &ctx) stamps an expiry from the context clock and the credential’s own lifetime; expiring_at(..) sets one by hand; store.expires_at(&key) reads it back. A store can hold a seat for the key — seat_refresher / unseat_refresher / has_refresher — and the seal’s resolution renews a stale seated credential at the moment of use (Freshness::Ensure, the default; Skip places it as it stands). A failed renewal aborts the request even under optional(): optional describes tolerable absence, and a credential whose upkeep broke is not absent. store.refresh(&ctx, &key) drives the seat by hand; the OAuth2 chapter shows how a flow seats one.
  • Carry key material. AuthEntry::from_key_material(..) and with_key_material(..) hold the custody handle a signer signs with, beside any placed parts — AWS temporary credentials are a placed session token next to a signing key. The seal refuses a placed header that collides with a header the signer declares as its own output.
  • Come from a source. AuthStore::with_source(..) / from_source(..) install an AuthSource, the pluggable backend consulted when the entry map misses: a vault, a keychain, a file. It answers a fetch(ctx, key) with the same bundle the map would have held, so consumers never know the difference.

Auth: the endpoint’s declaration

An endpoint declares its need with an Auth, returned from auth():

Auth::DEFAULT              // draw from AuthKey::Default (the common case; also the default)
Auth::named("management")  // draw from AuthKey::Named("management")
Auth::NONE                 // send no credential at all

An Auth names zero or more credentials, each carrying an optional flag, so two builder methods tune how strict a requirement is:

Auth::DEFAULT.optional()   // inject the credential if present, proceed anonymously if not
Auth::named("x").required() // the default stance: fail if the credential is missing
Auth::DEFAULT.redacted()   // keep the credential's shape, place REDACTED_PLACEHOLDER instead

These three stances map onto how real services treat authentication:

  • required() — the default. Many services expect every call to be authenticated, and for those a request with no credential shouldn’t even leave the machine — sending it just earns a 401 round trip. required() fails fast in that case, catching a missing token before anything goes on the wire.
  • optional() — auth-capable but not auth-mandatory. Some services accept a credential without demanding one: send a token and you get the authenticated view, send nothing and you get the public one. optional() captures exactly that — inject the credential if the store has it, otherwise proceed anonymously. This is what makes icecast’s public-stream client work: its endpoints declare .optional(), so a call with no stored credential simply goes out unauthenticated rather than erroring.
  • NONE — authentication must not be attached. A few endpoints should carry no credential at all, and NONE guarantees it: the auth header is left off even when a credential is present. That guarantee is the point — it stops you from leaking a token to a place it doesn’t belong. The clearest cases are endpoints that mint credentials (a login or token-exchange call has nothing to authenticate with yet), and requests to a pre-signed URL: an authenticated endpoint hands back a download location whose authorization is already baked into the URL’s signature, so the follow-up download is modeled as its own NONE endpoint — attaching your API credential there would be both unnecessary and a needless disclosure.

There is a fourth stance for fixtures and logs. redacted() keeps the shape of the request’s authentication — the headers and query parameters a live placement would write, the signer’s declared outputs — with REDACTED_PLACEHOLDER standing where each value would go; nothing is renewed and no signer runs, so the request holds no credential at all. RequestOverrides::redact_credentials() applies the same stance to one send, which is how the recorder captures a request’s redacted twin — the redaction chapter and Fixtures and Sensitive Data.

Most endpoints never call auth() explicitly — the framework default is Auth::DEFAULT.required() — and you override it only to reach a named tier, relax to optional(), or opt out with NONE.

The endpoint also says where the credential rides

Which header carries a credential is endpoint vocabulary, not credential vocabulary. The clearest case is a realtime API reachable from a browser: a WebSocket constructor takes a URL and a subprotocol list and nothing else, so the token has to ride Sec-WebSocket-Protocol there — while the same service’s HTTP endpoints take the same secret as Authorization: Bearer …, drawn from the same store slot.

Two builders on the declaration say so:

Auth::DEFAULT.via_header(X_API_KEY, "")          // write the token into this header
Auth::DEFAULT.append_header(SEC_WEBSOCKET_PROTOCOL, "xai-client-secret.")  // offer it in this list

via_header writes the header, replacing whatever is there; append_header extends its comma-separated list, so a session’s static subprotocol entries stay first and the credential lands after them. The prefix is joined to the token verbatim, exactly as a stored prefix is — "" for a header that takes the bare token. A directive replaces the stored credential’s own header placement for that one request, and it carries a recipe rather than a secret: the endpoint never sees the token.

An endpoint that needs two credentials at once — an application key beside a user token — chains .and(…), and every builder refines the requirement added most recently:

Auth::DEFAULT.and(Auth::named("app").via_header(X_APP_ID, ""))

How a declaration becomes a credentialed request

The endpoint’s declaration reaches the seal as a marker on the request head, and the seal resolves it against the store. It is one step of the request lifecycle:

graph LR
    ep["endpoint.auth()<br/><small>→ key, optional, placement</small>"]
    lm["the seal<br/><small>looks up the key</small>"]
    hdr["every part applied<br/><small>headers set, query appended</small>"]
    ep --> lm --> hdr

The seal runs after every pipeline member and after the rate-limit gate, with nothing between it and the wire. If the declaration is optional() and the store has no matching entry, placement is skipped and the request proceeds without a credential.

That position is what the whole model rests on, and it buys three things. First, secrecy: for the whole member band the request simply has no credential in it, so a logging or tracing member running in the middle can’t capture a token — there is nothing there to capture yet. Second, freshness: resolution renews a seated credential at the moment of use, so it goes stale neither during a runner’s backoff nor during the gate’s own wait. Third, an intact signature: there is no step between the seal and the wire for anything to occupy, so what a signing scheme signed is exactly what crosses the socket.

The scheme surface

Wiring auth up touches three things, each covered next:

  • An auth scheme is a marker type naming a flow handle — the struct whose inherent authenticate(...) has that scheme’s parameters. The framework ships Token and Basic for a credential handed in and stored; the auth add-ons ship the network ones; #[derive(AuthScheme)] on a carrier struct derives one that mints through an endpoint of your own.
  • #[capi(interface)] on your API struct makes it be the interface it wraps: it derefs to the handle stored in its field — which is how the default scheme’s authenticate(...) becomes api.authenticate(...) — and emits the delegating interface impls.
  • flow::<S>() on the interface reaches every other scheme, and as_role("…").flow::<S>() reaches one at a named tier.

The layer-1 header carriers

At the bottom are the credential types the Token and Basic schemes and the add-on flows produce, all of which redact their secret in Debug and mark the header sensitive. Each hands the store a scheme_prefix() and a token() — the split the entry keeps:

  • BearerAuthHeader::new(token)Authorization: Bearer <token>.
  • BasicAuthHeader::new(username, password)Authorization: Basic <base64> (behind the facade’s auth-basic feature).
  • AuthorizationHeader::new(prefix, value) → the general Authorization: <prefix> <value>, used for custom schemes and API keys.

The config side of this — the ApiConfig::auth_store override, AuthConfigExt, and the new_with_auth_header custom-header path — you already stood up in the config chapter. The next chapter wires the entry point that fills the store.

auth.rs and interface.rs: Wiring the Entry Point

Every client has an entry point — the GrokApi / AmazonS3Api / IcecastConnect type a user constructs and calls query() on. Building it takes two files that work as a pair, and understanding why they’re two files is the key to reading either.

Why authentication lives here

It’s worth understanding why the auth entry point is shaped the way it is, because the two-file split below is a direct consequence of it.

Authentication belongs to a service the same way the config does — it’s part of a service’s contract, not part of any one call. For a plain token that observation would be enough to keep auth in the config: a token is just a value you stash and attach later. But not every scheme is a stored value. OAuth2, a network token-exchange, a login flow — these have to make a request to obtain the credential in the first place, and making a request needs a client. The config has no client; the interface does — it owns the config and the client together. So auth can’t live purely in the config; it has to live at the interface level. That’s more machinery than a bare token would justify, but it’s the unavoidable cost of supporting schemes that reach the network.

Two design goals then shape how that machinery is exposed:

  • A client library should never have to write async code. An endpoint is pure description, and the framework supplies both sync and async execution from that one definition. Auth that hits the network threatens that promise — a token-exchange needs a sync path and an async path — unless someone else writes both.
  • There is one verb for authenticating, just as there’s one for querying. Every credential goes in through .authenticate(...), the same way every request goes out through .query(...). A user never has to hunt for login() vs set_token() vs exchange(); they reach for authenticate and the type system tells them what it expects.

Those facts collide into one hard problem: different schemes need different authenticate(...) signatures — a bearer token takes one string, basic auth takes two, a token-exchange takes whatever its endpoint needs — yet each signature must exist in both sync and async form, and none of it should require the client author to write async plumbing. Capi’s answer is the auth scheme: a marker type that names a flow handle, the struct whose inherent authenticate(...) has exactly the parameters that scheme needs, written once by the scheme’s own crate in both lanes. The framework ships the schemes for a credential handed in and stored (Token, Basic); the auth add-ons ship the network ones (OAuth2AuthorizationCode, JwtBearer, GoogleJwt, …); a client that mints through an endpoint of its own derives one. A client picks a scheme. It writes no auth code.

Why two files

auth.rs describes the credential; interface.rs presents it. Concretely:

  1. In auth.rs, you name the crate’s default scheme: a type alias for its owning flow handle — TokenFlow<BaseInterface<GrokConfig, Client>> for a bearer client — plus re-exports of the scheme markers your users reach. The module’s docs carry the user-facing auth story.
  2. In interface.rs, your public API struct stores that handle in its one field and carries #[capi(interface)], which makes the struct be the interface it wraps: it derefs to the handle, so the handle’s authenticate(...) is api.authenticate(...), and the handle derefs to BaseInterface in turn, so query and scoped are there too.

Here’s the whole loop for a bearer client.

config.rs: declare the placement

config: AuthConfigExt::bearer(CookieConfigExt::new(config)),

The config says where the credential rides — Authorization: Bearer … here. Use AuthConfigExt::new_with_auth_header(config, HeaderName::from_static("x-goog-api-key")) for an API key sent bare in its own header, and AuthConfigExt::with_placement(config, header, prefix) for anything else. A token handed in later carries no scheme of its own; the store dresses it.

The config also says it has a store, beside its auth_store() override:

impl HasAuthStore for GrokConfig {
    fn credential_store(&self) -> AuthStore {
        self.config.auth_store()
    }
}

ApiConfig::auth_store stays optional, because not every API needs a credential; HasAuthStore is the capability the stored-credential schemes bound on, which is why api.authenticate("xai-…") returns nothing — with the store guaranteed, the write cannot fail.

auth.rs: the default scheme

//! Authentication for the Grok API.
//! (the user-facing auth story: which calls store which credential)
use capi_rs::prelude::*;
pub use capi_rs::interface::{Token, TokenFlow};

/// The crate's default scheme: a stored token, owning the interface it
/// authenticates against.
pub type GrokAuth<Client> = TokenFlow<BaseInterface<GrokConfig, Client>>;

Token is the scheme; TokenFlow<I> is its handle, generic over how it holds the interface — by value here, because the API type owns it. That’s all auth.rs is: the alias, the re-exports, and the prose. The strategies chapter is the full taxonomy of schemes to name here.

The default scheme need not be a stored token. A service whose primary credential is minted over the network names that flow’s handle instead — the ringcentral client’s default is the OAuth2 JWT-bearer exchange, so api.authenticate(client_id, client_secret, jwt) is the exchange:

pub type RingCentralAuth<Client> =
    Oauth2Flow<BaseInterface<RingCentralConfig, Client>, OAuth2JwtBearer>;

// interface.rs — the constructor wraps the interface in that handle:
inner: BaseInterface::new(config, client).into_flow::<OAuth2JwtBearer>(),

into_flow::<S>() is the by-value counterpart of flow::<S>(): it hands the interface to the scheme’s handle instead of lending it.

interface.rs: the wrapper

#[capi(interface)]
#[derive(Debug, Clone)]
pub struct GrokApi<Client>
where
    Client: ApiClient,
{
    inner: GrokAuth<Client>,
}

impl<Client: ApiClient> GrokApi<Client> {
    pub fn new(client: Client, json_lib: Codec<Json>, form_lib: Codec<UrlForm>) -> Self {
        Self {
            inner: BaseInterface::new(GrokConfig::new(json_lib, form_lib), client)
                .into_flow::<Token>(),
        }
    }
}

Two things, each doing one job:

  • #[capi(interface)] emits Deref/DerefMut to the struct’s field and the delegating ApiInterface / ProvidesConfig impls, so the API type can also be passed where a signature asks for &impl ApiInterface<Config>.
  • <Client> stays generic, with no default. That is how one interface definition serves every transport the family offers — native, blocking, browser-wasm — and how a downstream app stays backend-agnostic in turn: it keeps its own Client parameter rather than pinning one. FollowUpChannel cannot fill this seat; it is a send channel, not a client.

A user now writes GrokApi::new_with_defaults(client), api.authenticate("xai-…"), and api.query(endpoint), and rustdoc lists authenticate on GrokApi’s own page under the methods reached through Deref.

The Deref chain

Here is the mechanism that makes both authenticate() and query() resolve.

query and scoped are inherent methods on BaseInterface; authenticate is an inherent method on the scheme’s handle. Your wrapper reaches both through a chain of Derefs:

GrokApi  ──Deref──▶  TokenFlow<BaseInterface<…>>  ──Deref──▶  BaseInterface  ──Deref──▶  GrokConfig
  (wrapper)          (the default scheme's handle)             (query/scoped/flow)      (base_url, …)

#[capi(interface)] emits the first Deref; every flow handle carries the second as part of its plumbing. A client with no scheme of its own — because its auth is signing-based (SigV4) or there’s no auth at all — stores BaseInterface directly, and the attribute emits the same Deref to it:

#[capi(interface)]
#[derive(Debug, Clone)]
pub struct AmazonS3Api<Client>
where Client: ApiClient {
    inner: BaseInterface<AmazonS3Config, Client>,
}

impl<Client: ApiClient> AmazonS3Api<Client> {
    // hand-written, because signing installs credentials rather than storing a header:
    pub fn authenticate(&self, access_key_id: impl Into<String>, /* … */) {
        self.inner.config().set_credentials(AwsCredentials::new(/* … */), region);
    }
}

Every other scheme: flow::<S>()

The default scheme is the one on the API type. Every other scheme is reached through flow, an inherent method of BaseInterface (so it is on the API type through the chain above) that builds the borrowing handle for one call:

api.flow::<GoogleJwt>().authenticate_from_bytes(key_json, scope).await?.auto_refresh();
api.flow::<OAuth2AuthorizationCode>().authenticate(client_id, secret, handler).await?.auto_refresh();

A scheme is a type, so the flows a client supports are the markers its auth module re-exports, gated by the features that bring their add-on in. Nothing is generated per flow, and the call shape is the same for every scheme.

Named tiers

Named tiers solve a service with privileged scopes. grok’s management endpoints require a different API key from ordinary calls, and a named tier lets the framework keep that key in its own slot and select it for exactly those requests. A tier is chosen where the handle is built:

api.as_role("management").flow::<Token>().authenticate("xai-…");

fills the Named("management") slot, while endpoints on the management host override auth() to Auth::named("management") to read from it. The same shape serves a network scheme at a tier: api.as_role("admin").flow::<JwtBearer>(). A client may wrap the common one in a one-line accessor, for discoverability and so the tier name has one home:

pub fn management(&self) -> TokenFlow<&BaseInterface<GrokConfig, Client>> {
    self.as_role(MANAGEMENT_AUTH_TIER).flow::<Token>()
}

No I/O happens in the accessor, so there is no lane split to write.

A tier handle is also a place to send from. api.as_role("management") yields an AuthTierInterface: every request sent through its query(endpoint) that asked for the default credential reads the management tier instead, so an endpoint that does not name its tier can still be run as that role. The handle carries the plain writes too — .set_token(token) and .clear() — and a generic mint, .authenticate(endpoint), which sends the endpoint and stores its decoded token under the tier; the minting request itself carries no caller-supplied authorization, because the credential it exists to produce is the one it would otherwise be asked to present.

You’re done when

  • config.rs declares the credential’s placement.
  • auth.rs names the default scheme’s owning handle and re-exports the markers users reach.
  • interface.rs has your #[capi(interface)] wrapper whose one field is that handle (or BaseInterface directly, for a signing/no-auth client).
  • api.authenticate(…) and api.query(some_endpoint) compile.

Which scheme to reach for, and how each maps onto config, auth, and interface, is the next chapter.

Auth Strategies: Choose Your Path

The model and the wiring are the same for every scheme; what changes is which scheme auth.rs names and what the config declares about placement. This chapter is the decision table, then the stored-credential schemes in depth. Request signers (SigV4, SharedKey, Digest) and the OAuth2/JWT family are big enough to get their own chapters, listed here for completeness.

The decision table

StrategyHow it wiresProven by
Nonethe API type stores BaseInterface directly; endpoints Auth::NONEa public endpoint
Bearer tokenconfig AuthConfigExt::bearer(…); default scheme Tokengrok
Bearer, named tierthe same scheme at a role: api.as_role("…").flow::<Token>()grok (management)
Basicconfig AuthConfigExt::new(…); default scheme Basicicecast
API key in a custom headerconfig AuthConfigExt::new_with_auth_header(…, HeaderName); default scheme Tokengoogle_places
API key in the query stringa scheme of your own writing AuthEntry::query_param — the worked example belowderive from the example
Custom scheme prefixconfig AuthConfigExt::with_placement(…, AUTHORIZATION, "X "); default scheme Tokenderive from the above
Fully custom valuewrite the store directly: config.auth_store().set_auth_entry(…), or a hand-written schemederive from the above
Endpoint token-exchange#[derive(AuthScheme)] + #[auth_scheme(endpoint = <Path>, refreshable)] on the carrier — authenticate() runs a network flow and can seat its own renewalpaychex → this chapter’s flow note
Cookie loginconfig CookieConfigExt::new(…).session_auth("sessionid"); #[cookie_auth(endpoint = <Path>)] on the login carrier→ this chapter’s flow note
OAuth2 / OIDC / JWT, vendor-bridgedcapi_auth_oauth2 / capi_auth_jwt schemes reached through flow::<S>(), plus a vendor crate’s config bridgegoogle_placesOAuth2, OIDC, and JWT
OAuth2 as the default schemethe client implements ConfigOAuth2Grant itself and stores the grant’s owning handle (Oauth2Flow<BaseInterface<_, _>, OAuth2JwtBearer>) as its API fieldringcentralOAuth2, OIDC, and JWT
Request signers (SigV4, Azure SharedKey, Digest)a credential-free OneShotSigner / RequestSigner held as config state, the key material in the AuthStore, declared per endpoint; the API type stores BaseInterfaceaws_s3, azure_tablesRequest Signers and Presigning

If your API isn’t in the table, it’s almost certainly a derivation of a row — a custom scheme is a placement declared on the config; a second key is a named tier. Reach for the closest row and adjust. One row has a quieter alternative: an API that is open by default but accepts a credential can leave every endpoint on Auth::DEFAULT.optional() with no store installed at all — the seal refuses only a required declaration it cannot answer — which is how the GraphQL runtime’s endpoints serve both a public schema and an authenticated one.

Bearer tokens

The common case, and the one from the wiring chapter: the config declares AuthConfigExt::bearer(…), and the default scheme is Token. Its authenticate(token) stores the token under AuthKey::Default with the "Bearer " prefix beside it, and every endpoint (which defaults to Auth::DEFAULT) gets Authorization: Bearer <token> placed by the seal, after every pipeline member and the rate-limit gate have run.

// config.rs
config: AuthConfigExt::bearer(config),

// auth.rs
pub type GrokAuth<Client> = TokenFlow<BaseInterface<GrokConfig, Client>>;

The scheme is the same for a key sent bare: only the config’s placement differs, which is the custom-header row below.

Basic auth

The default scheme is Basic; its authenticate(username, password) stores a BasicAuthHeader::new(username, password) in the store’s default header:

pub type IcecastAuth<Client> = BasicFlow<BaseInterface<IcecastConfig, Client>>;

BasicAuthHeader base64-encodes username:password (RFC 7617). Remember base64 is encoding, not encryption — Basic over anything but TLS sends the credential in effectively clear text. icecast pairs this with .optional() endpoints so a public stream with no stored credential still succeeds.

API key in a custom header

Many APIs want the key in a vendor header (x-goog-api-key, x-api-key) rather than Authorization. Point the auth layer at the header in the config, with AuthConfigExt::new_with_auth_header:

AuthConfigExt::new_with_auth_header(config, HeaderName::from_static("x-goog-api-key"))

The default scheme is still Token: the stored value is the raw key (no prefix), written into that configured header. google_places does exactly this. The endpoint side is unchanged — it still just declares Auth::DEFAULT; only where the credential lands differs.

Writing your own scheme

Nothing in the seam is reserved for the framework’s crates. A scheme is a marker, a handle holding the interface and a tier, the handle’s AuthFlow construction, the plumbing macro, and the inherent authenticate the scheme wants; a scheme that needs something from the config declares its own trait for it, the way the OAuth2 and JWT add-ons do. The worked example is an API key that rides in the query string — the one placement the header schemes don’t cover — with the parameter name coming from the config:

/// What the scheme asks of a config: the parameter the key rides in.
pub trait QueryKeyConfig: HasAuthStore {
    fn key_parameter(&self) -> &'static str {
        "key"
    }
}

/// The scheme: `api.flow::<QueryKey>()` hands back a `QueryKeyFlow`.
pub struct QueryKey;

pub struct QueryKeyFlow<I> {
    inner: I,
    tier: AuthKey,
}

impl<I> AuthFlow<I> for QueryKeyFlow<I> {
    fn build(inner: I, tier: AuthKey) -> Self {
        Self { inner, tier }
    }
}

impl AuthScheme for QueryKey {
    type Handle<I> = QueryKeyFlow<I>;
}

capi_base_interface::impl_flow_handle!(QueryKeyFlow<I> { inner, tier });

impl<I: InterfaceRef> QueryKeyFlow<I>
where
    I::Config: QueryKeyConfig,
{
    /// Stores `key` so every request carries it as a query parameter.
    pub fn authenticate(&self, key: impl Into<String>) {
        let config = self.inner.base().config();
        config.credential_store().set_auth_entry(
            self.tier.clone(),
            AuthEntry::query_param(config.key_parameter(), key.into()),
        );
    }
}

The store places the query parameter the same way it places a header, so the endpoint side is unchanged — it still declares Auth::DEFAULT. Prefer a header whenever the API offers one: query-string secrets leak into logs and proxies. An API whose only credential is that key stores QueryKeyFlow<BaseInterface<Config, Client>> as its default scheme, built with into_flow::<QueryKey>(), and api.authenticate("…") is the method above.

A scheme that performs I/O — a token exchange, a login — writes its authenticate once per lane, gated on the same predicate every add-on spells; capi_auth_jwt’s JwtBearerFlow is the reference. A scheme whose parameters should come from a struct’s fields, the way the endpoint-minting derive works, is a proc macro, and capi_macro_support carries the lane primitives and the handle template it emits.

Custom placements

Two escape hatches cover the long tail without a scheme of your own:

  • A scheme prefix. AuthConfigExt::with_placement(config, AUTHORIZATION, "Token ") makes the Token scheme store Authorization: Token <value>; the prefix owns its separator.
  • A fully custom value. When the credential needs computation the placement model doesn’t cover — several headers at once, a query parameter beside a header — build the AuthEntry yourself and write it with config.auth_store().set_auth_entry(tier, entry).

Two schemes don’t take a static secret at all:

  • #[derive(AuthScheme)] with #[auth_scheme(endpoint = <Path>)] turns the carrier struct into a token-exchange scheme: its handle’s authenticate(...) takes the carrier’s body fields in declaration order, builds the carrier, converts it into the endpoint through Into (the identity when the carrier is the endpoint, endpoint = Self), sends it, decodes a token from the response, and stores it at the handle’s tier. Two options shape the output: returns_token hands the decoded token back as well as storing it, and refreshable makes the credential one the store can date and renew. Under refreshable the derive also emits a Refresher that re-runs the exchange, and authenticate returns an Authenticated<_> whose .auto_refresh() seats it — so the call is spelled api.authenticate(id, secret)?.auto_refresh(), and a seated credential found near expiry is renewed at the moment of use. The mode decides the token type’s bound: Into<HeaderValue> for a plain scheme, Into<AuthEntry> plus ExpiringToken (the credential’s own lifetime) for a refreshable one. paychex derives its client-credentials exchange this way on the endpoint itself:

    #[capi]
    #[derive(Clone, WireModel, ApiEndpoint, AuthScheme)]
    #[auth_scheme(endpoint = CreateBearerToken, refreshable)]
    pub struct CreateBearerToken {
        #[endpoint(location = "body")] pub client_id: String,
        #[endpoint(location = "body")] pub client_secret: String,
    }

    The generic machinery underneath OAuth2’s grants is the next chapter; reactive renewal on a 401 (api.refresh_on_unauthorized()) and renewal from a background task (BackgroundRefresh) are orthogonal to the scheme and covered there.

  • #[cookie_auth(endpoint = <Path>)] — a different attribute, from capi_cookies rather than the derive — makes the carrier a login scheme: authenticate(...) runs a request whose Set-Cookie response populates the cookie jar (the config must implement HasCookieJar), storing no header at all. It emits the same handle shape, so it is named in auth.rs and reached through flow exactly like the others; it lives with the cookie jar because that is the capability that gives it meaning. A service that pairs the session with a CSRF token adds csrf = "X-CSRF-Token": the login’s decoded output is stored under that header, so the cookie and the token both ride every later request. On the config side, CookieConfigExt::new(base).session_auth("sessionid") wraps the cookie layer in an auth layer whose store reports that cookie’s presence to the seal, so the endpoints the session protects keep Auth::DEFAULT and only the login — which cannot present the session it establishes — declares Auth::NONE.

Both reuse the same two-file wiring; only the scheme and what authenticate does change.

Secrets in carriers and payloads

A derived carrier is a real struct, constructed on every authenticate call, and under refreshable the refresher keeps one for every renewal after that — so a live client secret sits in it, and any error path or member that debug-formats the endpoint would print it. Every carrier therefore hand-writes a Debug that prints [REDACTED] for the secret field. The payload the exchange returns is the developer’s to read, so it stays public and readable; its token field is marked #[wire(sensitive)], which keeps it out of logs and recorded fixtures without hiding it from the code that uses it. That is the family’s carrier-versus-payload rule: privacy and zeroization for a value the framework consumes, a redacting Debug and sensitive for one the developer keeps. The heavyweight credential machinery — OAuth2 grants, PKCE, OIDC discovery, JWT assertions — is the next chapter; request signers are the one after.

OAuth2, OIDC, and JWT

This is the heavyweight end of authentication: credentials that are obtained over the network — an OAuth2 grant exchange, an OIDC discovery round trip, a signed JWT assertion swapped for an access token — rather than typed in. However elaborate the acquisition, the result is the same as in The Auth Model: an access token lands in the AuthStore as a bearer credential — the token beside its "Bearer " prefix — and endpoints inject it exactly as before. This machinery lives in capi_auth_oauth2 and capi_auth_jwt, which a client depends on directly and gates behind its own feature. Both need a platform — std on native and WASI, or the browser-wasm target, whose JS bindings ride the crates’ std feature.

The shape: generic machinery + a vendor bridge

Three parties cooperate, and seeing the division up front makes the rest read easily:

  • The generic machinerycapi_auth_oauth2 (grants, PKCE, discovery) and capi_auth_jwt (client-signed assertions) — knows OAuth2 and JWT in the abstract.
  • A vendor crate — e.g. capi_auth_google — supplies the provider-specific constants (endpoints, scopes, the service-account shape) as ready-made defaults. When no vendor crate exists, the client supplies those constants itself; the ringcentral client does, below.
  • Your client bridges the two: its config implements the bridge traits (ApiAuthConfig, OauthConfigAccess, ConfigOAuth2Grant, ConfigJwtAuth), and its auth module re-exports the scheme markers users reach through flow. Each marker is an AuthScheme whose handle is Oauth2Flow<I, Grant> or JwtBearerFlow<I>; Grant selects which authenticate… methods exist.

google_places is the worked example, and it’s a good one because it offers three ways to authenticate at once — the default API key plus two network flows:

api.authenticate(key);                                             // API key, the default scheme
api.flow::<GoogleJwt>().authenticate_from_bytes(sa_json, scope).await?;            // service account
api.flow::<GoogleOAuth2<OAuth2AuthorizationCode>>().authenticate(id, Some(secret), h).await?;  // user consent

Each network scheme’s handle runs the exchange and stores the resulting bearer token. The markers are re-exported under the crate’s auth-jwt / auth-oauth2 features, so a user who only wants the API key doesn’t pull the OAuth2/JWT deps.

OAuth2 architecture

The grant machinery is a small cast:

  • OauthProviderDefaults — a provider template (endpoints, scopes, defaults). A vendor crate hands you one; OIDC discovery can produce one at runtime.
  • OauthUserInput — your client-specific input (client id, optional secret).
  • OauthGrantBuilder — merges the defaults and your input into a concrete grant config (.authorization_code() / .client_credentials()).
  • Credentials + ClientAuthMethod — the client’s own credentials and how they’re presented (ClientSecretBasic, ClientSecretPost, …).
  • Oauth2Authorizer — drives a built grant: authorize(handler) returns the Oauth2Token.

The grant kinds are a six-variant GrantKind: AuthorizationCode, ClientCredentials, DeviceAuthorization, Implicit, JwtBearer, and ResourceOwnerPassword, each with a scheme marker of the same name prefixed OAuth2 (OAuth2JwtBearer, …) and a builder method (.jwt_bearer(assertion), …). (Refreshing an existing token is a method on the authorizer, not a grant kind.) Whatever the grant, the token it yields becomes a bearer credential in the store.

The two headline grants

  • Authorization Code — the interactive browser-redirect flow. Its shared scaffold (AuthorizationCodeFlow) generates the CSRF state on construction — and a nonce when the provider defaults set a nonce length, and a PKCE challenge when they set a method or the client is public — and validates the returned state before exchanging the code, so the anti-forgery machinery is on by default. A public client — an SPA, a mobile or desktop app, anything that cannot hold a secret — calls authenticate_public(client_id, handler) instead: the client authenticates with no secret and PKCE stands in for it.
  • Client Credentials — the server-to-server, no-user flow: exchange the client id and secret directly for a token. Simplest to wire; no redirect, no PKCE.

PKCE

PKCE (RFC 7636) is folded into the authorization-code scaffold. The Pkce type carries a verifier and a challenge; the method is a CodeChallengeMethodS256 (the default) or Plain. The verifier is 32 random bytes drawn from the request context’s RNG (context.rng(), an ApiRng), base64url-encoded; S256 sends base64url(SHA-256(verifier)) as the challenge.

That the verifier comes from ApiRng matters for testing: the same injectable clock and RNG that make replay tests deterministic make a PKCE flow reproducible under test. You never hand-roll the randomness.

OIDC discovery

Rather than hardcode a provider’s endpoints, discovery fetches them. A DiscoveryTarget names either an issuer or a full discovery URL:

DiscoveryTarget::issuer("https://accounts.example.com")       // → appends /.well-known/openid-configuration
DiscoveryTarget::discovery_url("https://…/.well-known/openid-configuration")

target.discover(client, api).await fetches the document and returns an OauthProviderDefaults you feed to the grant builder — so discovery and the static vendor-defaults path converge on the same type. On the flow handle, authenticate_with_discovery(target, ..) sits between authenticate (defaults from the config’s bridge) and authenticate_with_grant (a grant you built yourself): it fetches the provider’s document and builds the grant from it. HTTPS is enforced on the discovery URL and the discovered token_endpoint/jwks_uri (unless you explicitly allow insecure endpoints, which you’d only do against a local test IdP). Discovery is not cached — each discover() refetches — so call it once at setup, not per request.

Keeping a token fresh

Access tokens expire. Every grant that can mint another returns an Authenticated<Oauth2Refresher> rather than the bare token, and one line on it is the whole opt-in:

api.flow::<GoogleOAuth2<OAuth2AuthorizationCode>>()
    .authenticate(client_id, Some(secret), handler).await?
    .auto_refresh();

That seats the refresher in the credential store under the key the grant minted at. From there the store renews the credential as part of resolving it — whenever a request finds it within a minute of expiry, the default RefreshPolicy skew that .auto_refresh_with(policy) changes — single-flight across concurrent callers. .refresh() on the same value renews by hand. Drop the line and nothing is retained: .into_token() takes the payload and leaves the credential to age, .into_parts() hands out both for a caller running a schedule of its own.

How a renewal is performed is fixed when the refresher is built, from what the grant actually yielded. A response carrying a refresh token yields a refresher that redeems it (RFC 6749 §6), adopting each rotated token the server returns — on_rotation(sink) is how one reaches persistent storage. A response without one yields a refresher that re-runs its own grant, which is what the three non-interactive grants can do: client credentials, resource-owner password, and a provider-issued JWT-bearer assertion all hold the material to ask again. The two interactive grants cannot — re-running them means putting the user back in front of a consent screen — so an authorization-code or device grant answered without a refresh token yields an outcome that renews nothing, and is_refreshable() says so before .auto_refresh() quietly seats nothing.

A process that persisted a refresh token from an earlier run skips the consent screen entirely: resume(client_id, client_secret, refresh_token) on the authorization-code and device flows runs the refresh-token grant now and hands back the same outcome an interactive authorization would.

Two more nets sit beside resolution’s own. api.refresh_on_unauthorized() seats a runner that renews on a 401 and dispatches once more — reactive rather than proactive, and useful when the service expires a credential early. capi_rs::extras::BackgroundRefresh drives a seated key on a timer, for a credential that must stay fresh while the client is idle rather than sending. Both act through the same seat, so no two of them mint twice.

The config bridges

The generic machinery reaches your config through bridge traits, which you implement per-API — they can’t be derived because they connect your config type to the provider surface. The OAuth2 side needs three: ApiAuthConfig (the JSON and urlencoded codecs the token exchange encodes through), OauthConfigAccess (which config carries the OAuth2 settings), and ConfigOAuth2Grant<Grant> (the provider defaults and the client’s input); the JWT side needs ConfigJwtAuth. In google_places they live in google_auth.rs:

impl ConfigOAuth2Grant<OAuth2AuthorizationCode> for GooglePlacesConfig {
    fn provider_defaults(&self) -> OauthProviderDefaults {
        capi_auth_google::google_provider_defaults()
    }
    fn build_user_input(client_id: String, client_secret: Option<String>) -> OauthUserInput {
        capi_auth_google::google_user_input(client_id, client_secret)
    }
}

impl ConfigJwtAuth for GooglePlacesConfig {
    type Token = capi_auth_google::JwtTokenResponse;
    type ExchangeError = capi_auth_google::GoogleAuthError;
    type Endpoint = capi_auth_google::ExchangeJwtAssertion<Self>;
    fn jwt_token_endpoint(&self) -> &str { capi_auth_google::constants::TOKEN_URI }
    fn jwt_json_lib(&self) -> Codec<Json> { self.config.json_lib() }
}

The pattern is always the same: pull the provider constants from the vendor crate, and wire the codec and token endpoint from your config. Gate each mod with its Cargo feature so an API-key-only build stays lean.

No vendor crate: the client is the bridge

A provider without a vendor crate is the same three impls with the constants written in place. The ringcentral client implements ApiAuthConfig, OauthConfigAccess, and ConfigOAuth2Grant for two grants — the JWT-bearer exchange and the authorization code — naming its token endpoint and HTTP Basic client authentication in its own OauthProviderDefaults. Because the JWT-bearer exchange is the credential most integrations use, the client makes that grant its default scheme: its API type stores Oauth2Flow<BaseInterface<RingCentralConfig, Client>, OAuth2JwtBearer> and api.authenticate(client_id, client_secret, jwt) is the exchange, with the authorization-code grant reached through flow.

JWT bearer, two shapes

RFC 7523’s JWT-bearer grant appears twice in the family, and the split is about who signs the assertion:

  • Provider-issued — the developer downloads a ready-made JWT from a console and presents it verbatim. That is capi_auth_oauth2’s OAuth2JwtBearer grant, whose refresher re-runs the exchange with the same assertion.
  • Client-signed — the client builds the claims and signs them against a private key it holds. That is capi_auth_jwt’s JwtBearer scheme (api.flow::<JwtBearer>()), whose JwtRefresher re-signs a fresh assertion on every renewal; Google service accounts are its canonical use.

JWT assertion auth

A JWT service-account flow signs a claims assertion with a private key and swaps it for a token — Google service accounts are the canonical case. Three pieces:

  • JwtBuilder::new(json_lib: Codec<Json>) builds the JWT, encoding through the wire-model codec.
  • JwtClaims carries the claim set. Because a claim set has runtime keys — a map, not a fixed struct — it can’t use #[wire(flatten)] (which rejects a map field); instead it ships a hand-written WireEncode that emits one flat map. This is a good real-world example of the wire model’s escape hatch: when a derive can’t express the shape, you implement the trait directly.
  • JwtSigningKey is a custody handle, not key bytes: from_rsa_pem(pem) loads a PKCS#8 PEM into one, from_handle(..) / from_key_material(..) wrap a key the credential store already holds, and kid() reports the key id the header carries. JwtAlgorithm is RS256 (the type is #[non_exhaustive]).

For Google specifically, capi_auth_google::GoogleServiceAccountKey parses the downloaded service-account JSON, and api.flow::<GoogleJwt>().authenticate(&key, scope) — the key by reference and the scope the token is minted for — handles the sign-and-exchange, depositing a bearer token in the store like any other flow and handing back an Authenticated<JwtRefresher<_>> to seat.

What the crates do not do

capi_auth_oauth2 states its own limits: discovery documents and JWKS are not cached (every ID-token validation refetches both); the at_hash claim binding an access token to its ID token is not validated; a JWKS whose keys carry no kid is unsupported; and ClientAuthMethod::PrivateKeyJwt carries the metadata for the assertion but signs none, so every token request under it fails.

With the credential families covered, one auth mechanism remains — schemes whose credential is computed over the request itself, and must run last: request signers.

Request Signers and Presigning

Some schemes present no stored secret at all. They compute a credential over the request itself — its method, path, headers, and often its body — and the signature is only valid for the exact bytes that leave the machine. AWS SigV4, Azure Storage SharedKey, and HTTP Digest are the built-ins. Because the signature depends on the finished request, signing runs as the first act of every transmit attempt: after every pipeline member has shaped the request and after the seal has placed the stored credentials, with nothing between it and the wire.

The credential and the signer are held apart on purpose. The signer is credential-free typed state on the config — Sigv4Signer::new(region, service) knows how to sign and holds no key. The key lives in the config’s AuthStore, as an AuthEntry carrying custody key material, and the seal maps it to the signer per request. That is what the crate calls the authentication plane: the store, the schemes, the signers, and presigning are one mechanism, and a request signer is one capability inside it.

The contract

Two traits, both from capi_authentication and re-exported through capi_core::authentication and the prelude. Which one a scheme implements says whether it reads responses.

Most schemes never look at the reply, and implement OneShotSigner:

pub trait OneShotSigner: MaybeSend + MaybeSync + 'static {
    fn sign(
        &self,
        ctx: &ApiContext,
        head: &mut RequestHead,
        body: &mut Bytestream,
        key: Option<&KeyMaterial>,
    ) -> Result<(), SignError>;

    fn needs_body(&self) -> bool { false }         // should the carrier buffer the body first?
    fn outputs(&self) -> Vec<HeaderName> { Vec::new() }   // the headers this signer writes
    fn key(&self) -> Option<AuthKey> { None }      // the store entry whose material feeds it
    fn as_presign_url(&self) -> Option<&dyn PresignUrl> { None }
    fn as_presign_policy(&self) -> Option<&dyn PresignPolicy> { None }
}

A scheme that does read the reply — Digest answering a challenge, or anything verifying a response signature — implements RequestSigner, which adds the response leg and the retry budget:

pub trait RequestSigner: MaybeSend + MaybeSync + 'static {
    fn sign(&self, ctx: &ApiContext, head: &mut RequestHead, body: &mut Bytestream,
            key: Option<&KeyMaterial>) -> Result<(), SignError>;
    fn handle_response(&self, ctx: &ApiContext, response: &mut Response)
        -> Result<SigningOutcome, SignError>;
    fn needs_body(&self) -> bool { false }
    fn max_retries(&self) -> u8 { 0 }              // > 0 selects the challenge/retry path
    // outputs, key, and the two presign upcasts, as above
}

handle_response returns SigningOutcome::Done, ::Retry (the hook that powers Digest’s challenge), or an Err — which fails the exchange rather than returning the response, so a scheme that verifies a response signature can refuse one it doesn’t trust. A blanket impl makes every OneShotSigner a RequestSigner with an empty response leg, so implementing the narrow trait gives up nothing; coherence then prevents a type from implementing both.

On the async lane both sign and handle_response return boxed futures — SignFuture<'a> and OutcomeFuture<'a>, Send unless the bounds are relaxed — because a signature is not always local: a custody backend may be an HSM, a KMS, or WebCrypto, and the signer awaits it.

The four members beyond sign each carry a contract:

  • key — the store entry whose material this signer needs. A named key is absolute: the seal resolves exactly that entry. A signer naming none (the common one-signer case) receives the endpoint’s single key-material credential — the entry its auth() declaration names — which is why a signed endpoint keeps Auth::DEFAULT (S3 uses Auth::DEFAULT.optional(), so an anonymous reader can still find a bucket’s region). Auth::NONE hands the signer None, and a scheme that needs a key fails with SignError::missing_key. Two distinct key-material entries on one marker are refused as AmbiguousSigningKey.
  • outputs — the headers sign writes. The seal refuses a store placement into a declared output (PlacedOutputCollision): a placed credential the signature would overwrite is a misconfiguration worth naming. After each sign, the names are stamped into the request’s InjectedCredentials record, which is how fixture tooling classifies signer-written headers structurally — names travel, never values.
  • needs_body — whether the carrier should buffer the body before sign runs. A signer that reads the body itself reports false, which is what lets a body it does not need to read stay unread.
  • as_presign_url / as_presign_policy — the upcasts to the presigning capabilities, below, so one installed signer serves both jobs.

Key custody

sign never sees key bytes. The KeyMaterial it receives pairs an opaque KeyHandle with the scheme-public identity it signs under — AWS’s access key ID, JOSE’s kid — and the two travel as one value so they cannot separate on rotation. The handle exposes three operations, each with its algorithm from a closed enum: mac (a keyed MAC over a message), sign (an asymmetric signature), and derive (a scoped sub-key, returned as a new handle, so a derivation ladder such as SigV4’s four-step signing key maps onto chained handles). The framework exports nothing. A scheme whose primitive is none of those three ships its own Custody type and reaches it through KeyHandle::backend; that type draws its own perimeter, under the same rule — operations on the secret, never the secret.

Custody is the backend seam behind the handle: one implementation per held key. The crate ships in-memory custody — InMemorySecret for symmetric keys, InMemoryRsaKey for RS256 behind the rsa feature — which zeroes its bytes on drop; a remote backend implements the same trait, and because its operations are I/O the async twins return boxed futures. A credential carrier turns itself into a store entry by building that pair:

let material = KeyMaterial::new(
    KeyHandle::new(InMemorySecret::new(root)),   // "AWS4" + the secret key, in custody
    self.access_key_id,                          // the identity SigV4 signs under
);
match &self.session_token {
    Some(token) => AuthEntry::new(X_AMZ_SECURITY_TOKEN, token.as_str()).with_key_material(material),
    None => AuthEntry::from_key_material(material),
}

Temporary STS credentials are the two-part case: a placed session-token header beside the custody-held signing key, in one entry, so a refresher rotates the token, the key, and the identity atomically.

A scheme carrying its own backend builds the same pair onto that instead. RFC 7616’s hash forms are neither a MAC nor a signature, so HTTP Digest’s carrier puts the password in a private Custody type that leaves all three operations refused and computes inside itself:

let identity = self.username.clone();
let secret = DigestSecret {          // private; `impl Custody for DigestSecret {}`
    username: self.username,
    password: self.password,         // Zeroizing, with no accessor
};
AuthEntry::from_key_material(KeyMaterial::new(KeyHandle::new(secret), identity))

sign asks for that backend back with key.handle().backend::<DigestSecret>() and calls the scheme’s own operation on it, so the credential is an ordinary store entry with the store’s expiry, renewal and roles; SignErrorKind::KeyMismatch is the refusal when the entry under that key belongs to another scheme.

The carrier

A resolved signer travels as a Signing value: nothing (NO_SIGNING), a &'static signer, an Arc-shared one, or the two-layer composite below. Signing is also what applies it, at transmit — the carrier primes the body when the signer asks for it, maps the key, and calls sign.

Two layers, two owners

A request can carry two signatures, and they answer to different people.

The service scheme is protocol: if the API demands a signature, the endpoint must produce one. So the endpoint declares it, and the default is nothing:

// Endpoint — defaults to NO_SIGNING, so an endpoint that says nothing sends unsigned.
fn signer(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToSigner {
    config.service_signing()
}

That costs one line per signed endpoint and buys a real property: reading an endpoint impl tells you whether its requests are signed. An endpoint whose authorization rides the body or the query string simply says nothing.

The cosigner is the user’s own outer layer — an egress proxy that wants its credential on everything leaving the network:

// ApiConfig — the user's layer. Defaults to NO_COSIGNING; BaseConfig answers it
// from its cosigner cell, and a wrapping config forwards it.
fn cosigner(&self) -> impl ToCosigner { self.config.cosigner() }

It applies over the assembled request as-is, service signature included, and no endpoint can see or suppress it. Signing::cosign folds the two into the single carrier the request plumbing carries: either side empty returns the other unwrapped, both present sign inner-first, so the cosignature covers the service signature — which is what a proxy that verifies or strips the outer layer needs. Signing::layer_keys() reports each layer’s declared key, and the seal maps a credential for each.

The cosign slot admits only OneShotSigner implementors — Cosigning’s constructors are bounded on it. Under a composite, one layer has to own the retry loop, and that owner is the scheme speaking the protocol; a challenge scheme in the cosign slot is a compile error rather than a signature that silently never gets its challenge. A cosigner therefore never reads responses.

ToSigner and ToCosigner are sealed and accept exactly three shapes each: a &'static S, an Arc<S>, and an already-built carrier. A new scheme becomes usable by implementing a signer trait, never by implementing these.

Assembly resolves both layers, alongside the rate limiter and the credential store, into the traversal’s RequestPlan. One request can adjust its own layers through RequestOverrides:

RequestOverrides::new().with_cosigner(my_signer)   // countersign just this one
RequestOverrides::new().no_cosigner()              // don't countersign this one
RequestOverrides::new().no_signing()               // send without the service signature

There is deliberately no with_signer beside no_signing. Which requests the service requires a signature on is protocol, and the endpoint declares it; the one thing a call site legitimately knows better is that this request is not going to the service on its own — a batch or changeset member captured to ride inside an envelope that authorizes for the whole group, where a signature computed over a request that never leaves as a request would at best be wasted and at worst a credential sealed inside an envelope body.

Installing a service signer

The signer is ordinary typed state on the client’s config, the way a codec is, and the credentials are a store entry. aws_s3 uses the pattern end to end:

// config.rs — the store plus a slot for the one scheme S3 speaks
auth: AuthStore,
sigv4: SharedCell<Option<Sigv4Signer>>,

pub fn set_credentials(&self, credentials: AwsCredentials, region: impl Into<String>) {
    self.auth.set_auth_entry(AuthKey::Default, credentials.into_auth_entry());
    self.sigv4.set(Some(Sigv4Signer::new(region, SIGV4_SERVICE)));
}
pub fn service_signing(&self) -> Signing {
    self.sigv4.with(|s| s.clone().map(Signing::new).unwrap_or(NO_SIGNING))
}

// interface.rs — authenticate() installs credentials at runtime
pub fn authenticate(&self, access_key_id: impl Into<String>, secret_access_key: impl Into<String>,
                    region: impl Into<String>, session_token: Option<String>) {
    let credentials = match session_token {
        Some(t) => AwsCredentials::with_session_token(access_key_id, secret_access_key, t),
        None    => AwsCredentials::new(access_key_id, secret_access_key),
    };
    self.inner.config().set_credentials(credentials, region);
}

Every signed endpoint then returns config.service_signing() from its signer. Before authenticate runs the slot is empty and the marker resolves against an empty store, so a send fails locally with MissingAuth rather than reaching the service unsigned. S3’s POST Object is the one endpoint that declares no signer: its authorization rides the form body, and the SigV4 it does need travels on the body itself (below).

azure_tables is the same shape with a different signer: SharedKeySigner::table() in the slot, AzureKeyCredential::new(account, base64_key)?.into_auth_entry() in the store — the decoded key in custody, the account name as its identity.

The user’s cosigner is installed separately, on BaseConfig:

config.set_cosigner(EgressSigner::new(credentials));

Recall from the wiring chapter that a signing client names no scheme: its API type stores BaseInterface directly, and authenticate is a hand-written method that installs the credentials.

Streaming bodies

A signer that reads the body meets a limit the framework refuses to guess at. Priming a live stream for such a signer would buffer every byte of it — an upload of unknown size becoming an allocation of unknown size, silently, at the last moment before the wire. So the carrier asks the stream what it is, and a BodySource::Streaming answer stops the exchange with a typed StreamingBodyRefused instead of materializing it.

Two ways forward. A scheme that would otherwise cover the payload can be told not to: SigV4 reads x-amz-content-sha256: UNSIGNED-PAYLOAD (or STREAMING-UNSIGNED-PAYLOAD-TRAILER) as an instruction to sign that token literally in the payload-hash slot and never read the body — which is how an aws-chunked upload with a trailing checksum, framed by capie_aws_chunked, goes out unbuffered. Otherwise the caller decides the body fits in memory and hands over a buffered one; that judgement is the developer’s, not a limit for the framework to guess.

One-shot vs. challenge

Which path runs is the signer’s declaration, not the traversal’s:

  • max_retries() == 0 — what every OneShotSigner gets from the blanket impl. Sign once, forward once: no head snapshot, no body clone. SigV4, SharedKey, vendor HMAC schemes, and the overwhelming majority.
  • max_retries() > 0 — snapshot the head, take a lazy replay clone of the body, and loop sign → send → handle_response until Done or the budget is spent.

handle_response is consulted on every exchange either way; the budget only bounds whether a Retry is honored. A one-shot signer pays one virtual call for it and nothing else.

The replay clone shares the body’s buffer rather than copying it, so an empty-body Digest GET costs nothing and a seekable body replays by reading its source again; a streaming body keeps its transmitted bytes in the shared buffer until the exchange settles, which is what re-sending a body that can only be read once costs. The challenge loop runs inside the traversal’s transmit step, below every pipeline member, so a handshake round trip doesn’t re-run them.

The built-ins

AWS SigV4 (capi_auth_aws_sigv4) — AwsCredentials (new(access_key, secret) or with_session_token(..) for STS; into_auth_entry() builds the store entry) and the credential-free Sigv4Signer::new(region, service). A one-shot signer: needs_body() is false because it reads the body itself in sign when no unsigned-payload token is present, max_retries() is 0, and its declared outputs are Authorization, x-amz-date, and x-amz-content-sha256. It also presigns, and Sigv4Signer::bedrock_api_key(&entry, epoch_secs, expires_secs) mints, with no network call, the bearer token Amazon Bedrock accepts in place of a signature — a SigV4-presigned request wrapped in the envelope AWS expects.

Azure Storage SharedKey (capi_auth_azure) — SharedKeySigner, a OneShotSigner computing Authorization: SharedKey {account}:{signature} over the canonicalized headers and resource; SharedKeySigner::table() is the Table service’s flavour. It reads nothing on the way back, which is what makes it eligible for either signing slot. azure_tables is the consumer.

HTTP Digest (capi_auth_digest, RFC 7616) — the challenge-response case, and the reason handle_response and max_retries exist. DigestCredentials::new(username, password).into_auth_entry() is the store entry, and DigestSigner is credential-free: an Arc<DigestSigner> is a signer handle the framework accepts directly, and sign reaches the password through KeyHandle::backend on the key material the seal mapped — so a protected endpoint declares Auth::DEFAULT beside its signer line. Its needs_body() is true (until the challenge arrives it cannot know whether qop=auth-int covers the body) and max_retries() is 1, because Digest works in two passes:

sequenceDiagram
    participant C as Signing
    participant S as DigestSigner
    participant Srv as Server
    C->>S: sign(head, body)  — no challenge yet, send bare
    C->>Srv: request
    Srv-->>C: 401 + WWW-Authenticate: Digest …
    C->>S: handle_response(401) → parse & cache challenge → Retry
    C->>S: sign(head, body)  — now compute Authorization from the challenge
    C->>Srv: retried request
    Srv-->>C: 200 OK → Done

The cached challenge and nonce counter are per-realm state, so every endpoint against one realm must reach the same signer: keep one on the config and hand a clone to each, rather than building one per endpoint. The credential is per entry rather than per signer, so that one signer still serves several accounts — as_role("ops") picks the entry a request signs under. The cnonce is drawn from ctx.rng(), so the flow is deterministic under replay tests.

Presigning

Presigning mints a credential-bearing artifact for a third party to use later: a URL a browser can GET without credentials, or the form fields a browser posts alongside a file. Nothing is sent when it’s produced, and the payload is normally unsigned, so it can’t be sign — it’s a separate pair of traits a scheme opts into and reaches through the two upcasts:

fn as_presign_url(&self)    -> Option<&dyn PresignUrl>    { None }
fn as_presign_policy(&self) -> Option<&dyn PresignPolicy> { None }

Signing forwards through those upcasts, so the value that signs a client’s requests is the value that presigns — a config needs no second handle to it. A signer that only signs in band reports SignErrorKind::Unsupported. Presigned artifacts never traverse the pipeline, so their credential resolution cannot ride the seal; capi_core::presign is the front door instead. It reads the config’s store, resolves the named credential strictly — no store, a lookup miss, or a source failure is an immediate typed error — and hands the resolved entry (key material, and any placed part the artifact must embed, such as a session token) to the signer’s presigning capability. A client wraps it as its own method:

let ctx = config.base_context().prepare();
let spec = PresignSpec::new(Method::GET, url, Duration::from_secs(900));
let url = presign::presign_url(config, &ctx, &AuthKey::Default, config.service_signing(), &spec).await?;

Both presign functions are async on the async lane, because a signature isn’t always local — keyless GCP calls signBlob, CloudFront and CloudHSM keys live in KMS, Azure user-delegation SAS fetches a delegation key — so a presigner can hold a client of its own and await it.

Policy signing takes two calls, because the document has to commit to the credential fields before it can be signed: S3 requires a condition for every form field it’s submitted with, including the signer’s own.

let signer   = config.service_signing();
let begun    = presign::begin_policy(config, &ctx, &AuthKey::Default, signer).await?;
let document = /* the caller's conditions plus begun.fields(), encoded */;
let signed   = presign::finish_policy(&ctx, signer, &begun, &document).await?;

begin_policy captures the one instant the whole artifact is signed under — the expiry, the credential scope, and the signature all derive from it, and a second clock read could straddle a second boundary and leave them disagreeing. It reads that instant through the record/replay clock, so a presigned artifact captured in a fixture reproduces byte-for-byte. finish_policy needs neither the config nor the store — the PolicyPresign carries the resolution snapshot — so it is capi_authentication’s own function, re-exported from capi_core::presign to keep the three steps under one path.

A body that carries its own authorization

An endpoint whose authorization rides inside the body — S3’s POST Object, GCS POST policy — can’t call any of that itself: Endpoint::body is synchronous and performs no I/O, because it describes a request rather than performing one. So it hands over its two pure steps and lets request assembly sign between them, by returning the PolicySigned arm of RequestBody — the scheme that signs the policy beside the body’s two steps:

fn body(self, config: &Self::ApiConfig, context: &ApiContext) -> Result<impl ToBody, BodyError> {
    Ok(RequestBody::PolicySigned {
        signing: config.service_signing(),   // what signs the policy
        body: Box::new(PostObjectForm {
            json: config.json_lib(),
            rng: context.rng().clone(),      // this request's own RNG fork, for the boundary
            policy: self.policy,
            file: self.file,
        }),
    })
}

impl PolicyBody for PostObjectForm {
    // 1. encode the document, given the fields it must commit to
    fn document(&self, begun: &PolicyPresign) -> Result<Vec<u8>, BodyError> {
        encode(&self.json, &self.policy.to_document(begun))
    }

    // 3. assemble the form from the document and its signed fields
    fn assemble(self: Box<Self>, document: &[u8], signed: &PresignedPolicy)
        -> Result<PreparedBody, BodyError>
    {
        build_form(document, signed, self.file, &self.rng)
    }
}

Assembly matches that arm, calls begin_policy, runs step 1, calls finish_policy, then runs step 3. Every other body is RequestBody::Ready and is untouched. document borrows and assemble takes Box<Self>, so one value owns everything both steps need — and assemble is unreachable without a PresignedPolicy in hand, which makes the ordering structural rather than checked. For a body with no fields worth naming a type over, PolicySignedBody::new(signing, document, assemble) is the same shape built from a pair of closures.

Such an endpoint returns NO_SIGNING from signer() and signs a policy: two different jobs on one request, and they do not have to agree. The request carrier decides whether an Authorization header is written; the policy is signed with the config’s credentials because that’s what the service verifies the upload against.

Signing a redacted request

A request captured for a fixture carries the shape of its signatures, not their values. When an endpoint’s declaration is Auth::DEFAULT.redacted(), or a send carries RequestOverrides::redact_credentials(), the seal maps the keys with redact set and the carrier writes REDACTED_PLACEHOLDER into every header the signer declares in outputs() instead of signing. No custody operation runs, so the request never holds a credential — which is why outputs() must name every header sign writes. The redaction chapter has the rest of that story.

Writing a custom signer

For a scheme that isn’t one of the built-ins, implement OneShotSigner — or RequestSigner if it reads responses:

  • sign(ctx, head, body, key) — mutate head.headers / head.specs_mut(); take the material from key (SignError::missing_key when you need one and got None); read the body non-destructively with body.inspect_data() if your signature covers it; draw any nonce from ctx.rng().
  • outputs() — every header sign writes.
  • key() — leave it None unless the scheme signs with an entry other than the one the endpoint declares.
  • needs_body()true only if you want the carrier to buffer the body before sign runs.
  • handle_response (RequestSigner only) — return Retry (and set max_retries() above 0) only if your scheme renegotiates; return Err to reject an exchange you can’t verify; otherwise Done.

Errors carry the scheme that produced them, so a failure stays legible when more than one signer is in play:

SignError::missing_key("AWS4-HMAC-SHA256")
SignError::missing_state("Digest", "no cached challenge for this realm")
SignError::invalid_input("AWS4-HMAC-SHA256", SignLocus::Header(name), "not canonicalizable")
SignError::scheme_failure("SharedKey", detail)
SignError::unsupported("Digest", "presign_url")

That completes authentication. Errors and Decoders turns to the response side — modeling your domain error.

Modeling Your Domain Error

This is the framework’s central bargain, and idea #5 from the mental model: the framework owns every generic failure; you model exactly one thing — the parsed domain error your API returns in an error-status body. Everything else — a dropped connection, a body that won’t parse, an unexpected status, an error response that isn’t your API’s error shape — the framework already has a variant for. This chapter shows what you write, and what you get for free.

The division of labor

ResponseError<E> is the framework’s response-failure type, and E — your domain error — fills exactly one slot in it. The other variants are already populated for you:

VariantWhenWho fills it
Api { error, annotations }your API returned a parseable error bodyyou (E)
Decoding { status, source }a body failed to parseframework
UnexpectedStatus(StatusCodeError)a 1xx/3xx, or an unmodellable statusframework
Unrecognized(UnrecognizedError)an error status whose body isn’t your error shapeframework
Bytestream(BytestreamError)the body stream itself failedframework

A parse failure is Decoding, bridged from the wire model’s CodecError. And a 4xx whose body you can’t parse is not a fabricated Api; it’s an honest Unrecognized that preserves the raw status and bytes. You never have to invent an error for a shape you didn’t expect.

The four-piece recipe

Modeling a domain error is four small pieces. grok’s is the canonical minimal case.

1. A public, code-bearing envelope — what callers inspect. #[non_exhaustive] so you can add fields later:

#[derive(Debug, Clone, Error)]
#[non_exhaustive]
#[error("Grok API error (HTTP {http_status}): {message}")]
pub struct GrokErrorEnvelope {
    pub code: Option<String>,   // machine-readable discriminant, when present
    pub message: String,
    pub http_status: u16,       // injected from the response, not the body
}

2. An all-Option wire proxy that derives the wire decode, plus a from_wire that lifts it — returning Option so a body that isn’t your error becomes Unrecognized:

#[derive(Debug, WireModel, WireDecode)]
pub(crate) struct GrokErrorWire {
    #[wire(default)] pub code: Option<String>,
    #[wire(default)] pub error: Option<String>,
}

impl GrokErrorEnvelope {
    pub(crate) fn from_wire(wire: GrokErrorWire, http_status: u16) -> Option<Self> {
        if wire.code.is_none() && wire.error.is_none() {
            return None; // not a Grok error body → fall back to Unrecognized
        }
        Some(Self { code: wire.code, message: wire.error.unwrap_or_default(), http_status })
    }
}

The proxy decodes through the wire model, exactly like your success types — the error path is not a special case. Every field is optional so a partial or unexpected body never fails to decode; whether it’s really your error is from_wire’s judgment.

3. A #[non_exhaustive] domain enum — the E your endpoints name:

#[derive(Debug, Error)]
#[non_exhaustive]
pub enum GrokError {
    #[error("{0}")]
    Api(GrokErrorEnvelope),
}

4. Two trait impls wiring it into the ladder:

impl FromErrorEnvelope<GrokErrorEnvelope> for GrokError {
    fn from_envelope(envelope: GrokErrorEnvelope) -> Self { Self::Api(envelope) }
}
impl ProvideErrorMetadata for GrokErrorEnvelope {
    fn code(&self) -> Option<&str> { self.code.as_deref() }
    fn message(&self) -> Option<&str> {
        (!self.message.is_empty()).then_some(self.message.as_str())
    }
}

FromErrorEnvelope is what the decoder’s status ladder calls to build your E; ProvideErrorMetadata surfaces the code and message uniformly so they’re readable as metadata even by a caller who doesn’t downcast to your type.

Both traits carry provided methods you override only when the API earns it. FromErrorEnvelope::from_status(status) builds an error from the status alone, for error responses with no parseable body (a bodyless HEAD, say); it returns None by default, which is what makes the decoder fall back to Unrecognized. ProvideErrorMetadata additionally offers request_id() and retry_kind() — grok implements neither, because its errors carry no request id and its retry behavior is fully implied by the HTTP status. Implement them when your service does better.

The modeling ladder: as much as the API deserves

Domain-error modeling is additive — start at the floor and add only what the API actually distinguishes:

  • Floor — a single Api(Envelope) with a raw code: Option<String>, like grok. xAI publishes no stable code vocabulary, so a raw string is the honest model.
  • Named codes — when an API documents a fixed set, promote code to an enum so callers match instead of comparing strings.
  • Per-operation enums — when different operations return genuinely different error shapes, give each its own E. aws_s3 goes all the way here: seventeen per-operation error enums plus a shared S3Error for every operation the S3 model does not enumerate, all built from a single S3ErrorEnvelope (and its one wire proxy), because S3’s operations really do fail in operation-specific ways.

Model the floor first; climb only when the API rewards it. Two variations on the floor are worth knowing. An envelope that nests — Google’s { "error": { "code", "message", "status" } } — is modeled as an outer wire struct holding the inner one, with from_wire reading through the nesting (google_places’s GoogleErrorEnvelope and GoogleRpcStatus). And a service with no discriminant at all — a plain-text body on a 5xx — skips FromErrorEnvelope and builds the error directly with ResponseError::api(MyError::Server { status, body }) from the decoder, which is what icecast_connect does with the first 512 characters of the body.

What the caller gets

A consumer never sees ResponseError<E> directly — a query() returns Result<Output, QueryError>, a type-erased error that works the same regardless of E. From it they can:

  • Triage without downcastingerr.kind() returns an ErrorKind (Authentication, InvalidRequest, Transport, RetriesExhausted, StatusClient, StatusServer, Api, Decoding, Io, Cancelled, …), and the is_*() family reads ergonomically: err.is_rate_limited(), err.is_retryable(), err.is_transport(). Cancelled is the request’s own cancel token firing, and it is the one kind that is not a condition of the network: the token stays fired, so is_retryable() is false and the retry runner stops on the first attempt. The knobs that fire without ending the call for good keep Transport and read back through err.is_timed_out() / err.is_stalled(); a knob the client could not enforce at all is InvalidRequest, with err.refusal() naming it.
  • Read metadata genericallyerr.metadata() surfaces the code/message your ProvideErrorMetadata exposed, no downcast needed; it is populated when the decoder captured it through the envelope ladder, and None for non-Api errors. err.status() answers for an UnexpectedStatus, an Unrecognized, or a Decoding built with a status — for a modeled Api error the status lives in your envelope (envelope.http_status), so status() is None there and the domain type is where to read it. err.response_head() hands back the whole observed head when a query failed after one arrived.
  • is_retryable() reads more than the kind. The ladder captures a retry classification with every modeled error — Throttling for a 429, Transient for 408, 500, 502, 503, and 504, or whatever the envelope’s own retry_kind() says — so an Api error can be retryable too; is_rate_limited() is that classification or a 429 status.
  • Recover the typed errorerr.api_error::<GrokError>() downcasts and returns Option<&GrokError> for callers who do want the full domain type:
match api.query(endpoint).await {
    Ok(response) => { /* … */ }
    Err(err) => {
        if err.is_rate_limited() { /* back off and retry */ }
        if let Some(GrokError::Api(envelope)) = err.api_error::<GrokError>() {
            eprintln!("HTTP {}: {} (code {:?})", envelope.http_status, envelope.message, envelope.code);
        }
    }
}

That two-level design — cheap kind()/is_*() triage for everyone, api_error::<E>() recovery for those who need it — is why you model only the one envelope: the framework turns it into both. The machine that drives this ladder — deciding status-first, calling from_envelope, falling back to Unrecognized — is the decoder, next.

The Decoder Contract and the Status Ladder

The decoder is the machine on the way up the funnel: it turns a Response (a response head plus a Bytestream body) back into your typed Output, or into the domain error you just modeled. It has one load-bearing rule — check the status first, then parse — and understanding that rule is most of what you need to write or read one.

Marker-based dispatch

An endpoint names its decoder through the Decoder associated type — usually BodyDecoder, the standard body decoder:

impl Endpoint for GetApiKey {
    type Decoder = BodyDecoder;
    // ...
}
impl DecodeBodyFn for GetApiKey {
    fn decode_fn() -> BodyFnDecoder<Self> { GrokResponse::decode }
}

Decoder is a marker type, not the decode logic itself, and that indirection is deliberate. The contract the pipeline actually drives is ResponseDecoder<Endpt>, which the marker implements: it snapshots State from the endpoint before body(self) consumes it, records what the response will decode into on the request head in prepare_request, and receives the response — with the door for any follow-up send — in decode. If decoders were selected by blanket impls, the impls for “decode a body”, “decode a stream”, and “decode a paged response” would overlap and the compiler would reject them; a marker per lane lets each endpoint pick one unambiguously. An endpoint that needs the whole contract implements ResponseDecoder on itself and declares type Decoder = Self. The Decoder System in Depth is the contract in full; this chapter is the buffered-body lane every JSON client lives in.

The status ladder

Every body decoder walks the same three-rung ladder. This is grok’s decode, and it is the rule:

pub fn decode<T: for<'de> WireDecode<'de>, E: FromErrorEnvelope<GrokErrorEnvelope> + StdError>(
    body: Vec<u8>, header: ResponseHead, config: &GrokConfig, _context: ApiContext,
) -> Result<T, ResponseError<E>> {
    Self::check_response_status(&header)?;                     // rung 3: reject 1xx/3xx
    if header.status.is_client_error() || header.status.is_server_error() {
        return Err(build_api_error::<E>(&body, config, header.status));  // rung 2
    }
    config.json_lib().decode_from_slice::<T>(&body).map_err(Into::into)  // rung 1
}
  • 2xx → decode T. Parse the body into your output type. A parse failure here is an honest ResponseError::Decoding (the CodecError bridges via .map_err(Into::into)) — never a fabricated Api. A 200 that doesn’t match your type is a decode bug, and the error says so.
  • 4xx/5xx → build the domain error. Hand off to build_api_error, which produces your Api(E) from the envelope; failing that, from the status alone through FromErrorEnvelope::from_status (the rung a bodyless HEAD needs); failing that, an Unrecognized that preserves the raw status and bytes.
  • 1xx/3xx → UnexpectedStatus. A status the decoder can’t model — including a redirect, surfaced as StatusCodeError::Redirection { status_code, location }. ResponseError::unexpected_status_with_headers(status, &headers) builds it with the Location lifted off the head, so a client’s status check need not.

build_api_error is where the codec is named, and the only place:

pub(crate) fn build_api_error<E: FromErrorEnvelope<GrokErrorEnvelope> + StdError>(
    body: &[u8], config: &GrokConfig, status: StatusCode,
) -> ResponseError<E> {
    ResponseError::from_envelope_or_unrecognized(status, body, |bytes| {
        let wire = config.json_lib().decode_from_slice::<GrokErrorWire>(bytes).ok()?;
        GrokErrorEnvelope::from_wire(wire, status.as_u16())
    })
}

from_envelope_or_unrecognized is the shared core ladder — the code calls it the degradation ladder: you pass a closure that tries to parse the error body, and it returns Api on Some, tries from_status on None, and falls to Unrecognized last. A parsed envelope also carries its ErrorAnnotations (the code, the message, the retry classification) across erasure into QueryError. The codec lives inside the closure, so the core ladder stays codec-agnostic — the same reason config accessors, not the core trait, hold json_lib().

The shared per-crate decoder

Notice GrokResponse is a zero-sized struct whose decode/empty are plain associated functions. That’s the pattern: one shared decoder per crate, its methods coerced to function pointers by DecodeBodyFn::decode_fn. Every endpoint that returns JSON names GrokResponse::decode; every 204/no-content endpoint names GrokResponse::empty (Output = (), success short-circuits, errors still ladder). The status logic is written once and reused across the whole client.

The body-decode trait family

BodyDecoder dispatches to whichever of four traits you implement — pick exactly one per endpoint:

TraitUse when
DecodeBodyyou want to write the decode inline on the endpoint
DecodeBodyFnyou share a crate-wide decoder function (the common case)
DecodeWrappedBodyFnthe wire body is a wrapper and Output is its inner field — decode the wrapper, return the inner
TryDecodeWrappedBodyFnsame, but the unwrap is fallible

The wrapped variants are how response-unwrapping works without a second type in your public API; endpoint shapes shows them in use.

Every one of the four carries a defaulted response_specs(config) hook. Declaring it — Some(ResponseSpecs::wire::<Shipment, ParcelErrorWire>(config.json_lib())) for the common JSON shape — states what a 2xx and a 4xx/5xx decode into before the request is sent, and BodyDecoder writes the answer onto the request head. That is what lets the recorder mask a response body’s sensitive fields structurally and compare it under the model’s classes at replay; a decoder that declares nothing falls back to comparing bytes through the content-type registry. Declare it once per client, on the shared strategy below, when the convention fixes the codec and the envelope.

Multi-format decoders

A client speaking more than one format writes one decode_<fmt> method per format, each naming that format’s codec. aws_s3 decodes XML bodies:

pub fn decode_xml<T: for<'de> WireDecode<'de>, E: FromErrorEnvelope<S3ErrorEnvelope> + StdError>(
    body: Vec<u8>, header: ResponseHead, config: &AmazonS3Config, _context: ApiContext,
) -> Result<T, ResponseError<E>> {
    Self::check_response_status(&header)?;
    if header.status.is_client_error() || header.status.is_server_error() {
        return Err(build_api_error::<E>(&body, config, header.status));
    }
    config.xml_lib().decode_from_slice::<T>(&body).map_err(Into::into)  // xml_lib, not json_lib
}

Same ladder, different codec accessor. The status handling is identical; only the decode_from_slice call names a different format.

Strategy decoders and the orphan rule

BodyDecoder is BodyDecoder<S = ViaEndpoint>, generic over a DecodeBodyStrategy. The default strategy, ViaEndpoint, forwards to the endpoint’s own companion impl — the four traits above. Strategy types exist because Rust’s orphan rule (E0210) forbids the blanket impls you’d otherwise reach for: a strategy is a concrete type that carries the decode behaviour for a whole family of endpoints, sidestepping the overlap while staying reusable. A client with a uniform response convention names one per convention and aliases it — pub type PaychexSingle = BodyDecoder<PaychexSingleResponse>; — so its endpoints write type Decoder = PaychexSingle; and no companion impl at all. The strategy is also where response_specs is best declared once. The Decoder System in Depth shows the shape.

Two more response parsers live beside the decoders in capi_base_decoders: ResponseBoundary::from_content_type(&bytes)?.split(body) splits a buffered multipart/* response into its parts (the batching chapter’s envelopes go through it), and LinkHeader::of(&headers).next(&base) reads pagination relations off a response’s Link headers.

Two footguns

  • A non-body decoder silently drops the error body. Stream, binary, and WebSocket decoders don’t run the body ladder, so on a 4xx they’d lose your error detail unless you override their decode_error hook to run the same build_api_error. grok’s streaming and download decoders do exactly that — reuse the shared build_api_error rather than re-deriving it. The chunked decoder has no such hook at all. (See Streaming.)
  • A Decoding built from ? carries no status. From<CodecError> maps to ResponseError::decoding(source), whose status is None, because the conversion runs with no status in scope. That is fine on the 2xx rung — the status is known to be a success — but a hand-rolled decode error on any other path should be built with decoding_with_status(status, source) so a caller’s err.status() still answers.

Decoders keep calling into json_lib() and decode_from_slice without ever saying what those are. That’s the next part: the wire model — the layer that turns bytes into your types and back, and the #[wire(...)] vocabulary that shapes what these decoders parse into.

Why the Wire Model Exists

Every API client has to turn a Rust value into bytes on the way out and bytes back into a Rust value on the way in. Rust already has a superb answer to that problem in Serde, so a new one needs to justify itself. This chapter is that justification — what the wire model is, the two things it does that Serde’s design cannot, and why it is nonetheless optional.

What it is

The wire model is a model-first (de)serialization family. A type describes what it is on the wire through a derive-emitted &'static Binding — a static table of its name, fields, tagging, and format knobs — and every codec, value tree, and diagnostic is a thin consumer of that one description.

It lives in its own workspace (capi_wire) and has no dependency on Capi. It is a standalone library you could use for anything; a client names it as a dependency of its own, because the capi_rs facade re-exports the Capi framework workspace and nothing else. What ties the two together is priority rather than coupling: the wire model is designed around the needs of API clients first and other use cases second, so its conveniences are the ones API work keeps demanding.

If you know Serde, most of the surface will feel familiar on purpose — rename, rename_all, default, flatten, skip, the four enum tagging modes, untagged, even #[wire(crate = "...")] all mean what their #[serde(...)] counterparts mean. Some ideas are borrowed from the ecosystem around Serde rather than Serde itself: the proxy-type conversions and field-level transforms will read as familiar to anyone who has used serde_with.

The two structural reasons

Familiar surface, different machine. Two properties of Serde’s design are load-bearing for it and disqualifying here.

1. A type cannot describe itself without being instantiated. Serde’s model is value-driven: Serialize::serialize pushes a value’s fields into a Serializer as it walks them. There is no way to ask a type what its wire shape is. For a self-describing format that’s fine — JSON writes names as it goes. For a non-self-describing format it isn’t: protobuf addresses fields by number, not name, and a decoder must know the number→field mapping, the wire types, and the proto2/proto3 presence rules before it has any value in hand. The wire model’s Binding is exactly that missing description, available as T::BINDING without an instance. It’s why protobuf is an ordinary format here rather than a separate derive ecosystem — and the same static description is what lets the test framework compare fixtures semantically, by reading a type’s shape rather than guessing from bytes.

2. The core trait is not object-safe. fn serialize<S: Serializer>(&self, s: S) is generic over S, so dyn Serialize is illegal and “a codec chosen at runtime” needs a crate like erased_serde to bridge the gap. Runtime format selection is the entire point of a Codec handle, so the wire model defines an object-safe split instead of papering over a generic one. The result is no erased_serde on the path and no unsafe anywhere in the family’s own code; the wire workspace itself is no_std + alloc throughout (of the codecs, only capiw_quick_xml links the standard library). Codecs and Format Contracts shows the split.

There’s a third, softer reason. Serde has no room for a format to contribute its own vocabulary — #[serde(...)] is a fixed set. XML needs to say “this field is an attribute, not an element”; protobuf needs field numbers. The wire model reserves an extension bag in the binding that any format can write into and read back through typed helpers, which is how XML, urlencoded, and protobuf each add their own annotations without the core knowing anything about them. That mechanism gets its own chapter.

Why not a reflection crate

A reflection library such as Facet attacks the same first problem — making a type’s shape inspectable — and it is a genuinely interesting approach. The trade-off is implementation machinery: building a runtime picture of arbitrary Rust types means raw pointer work, and that means unsafe.

The wire model reaches a comparable place from the other direction. Because a derive already sees the type at compile time, it can emit the description as a plain &'static table, and reading that table is ordinary safe Rust. For a library that handles credentials and request bodies, “no unsafe anywhere” is worth more than the generality full reflection would buy.

What it adds for API work

Beyond the structural reasons, the wire model ships the conveniences that API integration keeps needing and that are otherwise re-invented per client:

ConvenienceThe problem it answers
Tri-state fieldsPATCH bodies must distinguish unchanged, set, and explicitly null
Open string enumsa documented value set that the service will grow later
Number laningAPIs that quote numbers as strings, and money that must never be f64
Datetime contractsepoch vs RFC 3339 vs a bespoke format — where a wrong unit is the costliest bug
Sentinel mappingservices that encode absence as "", -1, or 9999-12-31
Value classes and redactionmarking a field secret or non-deterministic, and a typed transform so a request is born without its secrets
Constant entriesa "jsonrpc": "2.0" or an @odata.type the wire needs and no Rust field should carry

Each has a Serde-ecosystem analogue you could assemble by hand. Having them in the model — machine-readable, in the binding — is what lets tooling act on them.

It is not required

The wire model is a convenience layer, and the boundary is worth stating plainly.

At bottom a Capi endpoint is a method, a URL, an optional body implementing ToBody, and a function over response bytes. None of that mentions the wire model. A client author who wants to use Serde in their own library can: derive Serialize/Deserialize, call serde_json directly in the body and decoder, and never write a #[wire(...)] attribute. Nothing in the framework prevents it. A client that speaks a binary protocol can skip serialization libraries altogether and hand-roll bytes — the icecast_connect reference client does exactly that, storing no Codec at all.

What you give up is everything above: the swappable codec, the format markers that catch a JSON/XML mix-up at compile time, and the conveniences table. The relationship is the same one Serde has with the rest of Rust — technically optional, practically synonymous. The wire model is the default path because it earns the position, not because the framework depends on it. That argument is made in full in the design rationale.

How this part is organized

The next chapter is the one everyone needs; the rest are reference you can read when a type forces the question.

Codecs and Format Contracts

A framework has to decide where the choice of wire format lives. Hardcode one and every downstream user inherits it. Thread it through the type system as a generic parameter and it infects ApiConfig, Endpoint, the interface, and every signature in between. The wire model takes a third path: a Codec<Format> — a small Copy handle carrying a runtime-selected byte engine plus a compile-time format tag. The runtime half keeps the API surface clean; the compile-time half makes a boundary that needs JSON reject a codec that speaks XML before the program runs.

Once you understand what a Codec<F> is and where the markers and engines live, every encode/decode line in the rest of the book reads the same way.

The Codec<F> handle

A codec is one Copy value:

pub struct Codec<F = Unmarked> {
    inner: &'static dyn WireFormat,   // the byte engine (encode + decode)
    content_type: &'static str,       // e.g. "application/json"
    _format: PhantomData<fn() -> F>,  // the compile-time format tag — zero-sized
}

Three properties fall out of that shape, and all three are load-bearing:

  • It is Copy, with no bound on F. A config stores a codec by value — no Arc, no lifetime, no reference counting — and stays Copy itself. The PhantomData<fn() -> F> is what keeps Copy/Send/Sync available for every F; a naive PhantomData<F> would drag F’s own bounds in.
  • The backend is &'static. A codec handle is exactly one fat pointer plus a string pointer. It can be built in a const fn, so a codec can be a static, and copying it is trivial.
  • F is a pure phantom. The marker carries no data and costs nothing at runtime. It exists only so the compiler can tell one format from another.

You rarely construct a codec by hand. A codec crate hands you one:

let json: Codec<Json>    = capiw_serde_json::codec();  // content type "application/json"
let form: Codec<UrlForm> = capiw_urlencoded::codec();
let xml:  Codec<Xml>     = capiw_quick_xml::codec();   // content type "application/xml"

and you store it on your config as a typed field (GrokConfig, abridged — the real one adds a Codec<Protobuf> under its grpc feature):

pub struct GrokConfig {
    // ... base config, auth, cookies ...
    json: Codec<Json>,
    form: Codec<UrlForm>,
}

The handle’s own surface is small:

impl<F> Codec<F> {
    pub fn content_type(&self) -> &'static str;
    pub fn encode_to_vec(&self, value: &dyn DynEncode) -> Result<Vec<u8>, CodecError>;
    pub fn encode_into(&self, value: &dyn DynEncode, out: &mut Vec<u8>) -> Result<(), CodecError>;
    pub fn decode_from_slice<'de, T: WireDecode<'de>>(&self, bytes: &'de [u8]) -> Result<T, CodecError>;
    pub fn erased_decoder<'de>(&self, bytes: &'de [u8]) -> Result<Box<dyn ErasedDecoder<'de> + 'de>, CodecError>;
    pub const fn erase(self) -> Codec<Unmarked>;   // drop the marker for format-agnostic plumbing
}
  • encode_to_vec takes an erased value (&dyn DynEncode); a concrete value coerces at the call site — codec.encode_to_vec(&user). It returns a fresh Vec<u8>; encode_into appends to a buffer the caller owns instead.
  • decode_from_slice::<T> is the inverse: bytes in, a typed T out. The one mandatory allocation is a boxed decoder; monomorphization is confined to this call. erased_decoder hands that boxed decoder out for a model-driven walker to drive — the value tree and the recorder read bytes that way.
  • content_type() is registry/diagnostic data — the default MIME for the format. It is not automatically the request’s Content-Type header; an endpoint owns that (vendored types like application/vnd.foo+json are common), so nothing couples the wire header back to this string.

The calls the rest of the book makes are one layer up. Two extension traits in capi_core wrap the handle for the two request planes, and capture the decode recipe beside the bytes — the codec and the type’s shape, as a BodySpec or a QuerySpec — so a request carries, with its very bytes, the description fixture tooling needs to read them back:

config.json_lib().encode_body(&self.body_fields())?     // EncodeBody: bytes + BodySpec, for Endpoint::body
config.form_lib().encode_qs(&self.query_fields())?      // EncodeQs: text + QuerySpec, for Endpoint::query_string

EncodeBody is implemented for every Codec<F>; EncodeQs only for a Codec<F> whose F: QueryFormat — a format that emits key=value text, which is what makes Codec<UrlForm>::encode_qs exist and Codec<Json>::encode_qs a compile error. The bare decode_from_slice remains the decoder-side call: every config.json_lib().decode_from_slice(...) in the decoder chapter is one.

Why formats are type-tagged

The marker earns its keep the moment two formats are in play. A boundary that needs JSON names the format it needs, and the compiler enforces it:

fn needs_json(_: Codec<Json>) { /* ... */ }

fn give(codec: Codec<Xml>) {
    needs_json(codec); // compile error: Codec<Xml> is a distinct type from Codec<Json>
}

The clever part is where the format tag lives. The obvious way to make a format visible to the type system — a generic parameter like Config<Json> — would propagate through ApiConfig, Endpoint, BaseInterface, and every signature that touches them, infecting the whole surface. The wire model sidesteps that entirely: the marker sits in a PhantomData<fn() -> F> on a Copy handle, not in the handle’s data and not in the types that hold it. A config stores a Codec<Json> field — it is not itself generic over a format; the endpoint, the interface, and the pipeline never mention F; and format-agnostic plumbing that genuinely doesn’t care takes a Codec<Unmarked> (call .erase() to get one). So you get compile-time enforcement and a clean, un-infected API surface at once.

Two more properties fall out of the marker being a plain zero-sized type:

  • Third-party markers. Format is an unsealed, empty trait, so a new format needs one line — impl Format for TheirMarker {} — in its own crate. There is no central registry to edit and no orphan-rule wall. Where markers come from, and why that openness needs a community convention, is below.
  • Escape hatch. When you deliberately need to re-tag an erased handle, assume::<G>() re-pins the marker. It is unchecked — the one path that can attach a wrong marker — so use it only right after building a backend you know speaks G, and prefer erase() at the boundary over laundering handles through assume.

There is also a subset marker, QueryFormat: Format, for formats that can render a URL query string (flat key=value text — JSON bytes would be nonsense in a query slot). UrlForm opts in; the query plane accepts only codecs whose format does, so a wrong format in a query slot is a compile error, not a malformed URL.

Markers vs. codecs: the contract/engine split

There are two different things with similar names, and keeping them apart is the key to reading the crate list.

A format contract is the marker plus whatever type-level vocabulary the format needs. It is tiny and always-on. A concrete codec is a byte engine that implements the contract, and it is an optional dependency you can swap.

FormatContract crate (the marker)Concrete codec crate (the engine)Default content type
JSONcapiw_ext_json (Json)capiw_serde_jsonapplication/json
URL formcapiw_ext_urlencoded (UrlForm)capiw_urlencodedapplication/x-www-form-urlencoded
XMLcapiw_ext_xml (Xml)capiw_quick_xmlapplication/xml
Protobufcapiw_ext_protobuf (Protobuf)capiw_prostapplication/protobuf

All four contract crates and all four codecs are sibling repos of their own; neither the Capi framework workspace nor the capi_wire workspace holds a format. (The full map is in How the Crates Fit Together.) What matters here is the shape of the relationship: a codec crate exposes one function, codec(), that returns its marker’s handle —

// capiw_serde_json
pub fn codec() -> Codec<Json> {
    Codec::new(&SERDE_JSON, "application/json")
}

— so a config depends on the tiny contract for the type (Codec<Json>) and on the concrete codec only to build one. That split is what lets a downstream user swap the JSON engine without your types changing, and it is why XML and protobuf are first-class formats rather than bolted-on special cases: they go through the same Codec<F> machinery as everything else.

How much vocabulary a contract carries varies with the format. JSON is self-describing, so capiw_ext_json is just the marker — no decoration macro, no attributes. XML needs to distinguish elements from attributes, protobuf needs field numbers, and URL forms need a list convention, so those three contracts carry extra vocabulary through the extension mechanism, each with a decoration macro behind an off-default model feature. The marker mechanism is identical either way. So is the feature story of the codecs: capiw_serde_json, capiw_urlencoded, and capiw_prost are no_std with no features to set; capiw_quick_xml links the standard library.

The same handle shape serves the header plane. capiw_wire_headers::WireHeaders encodes a wire model’s fields into header values and back with no marker at all — a header map is a header map — and is what an endpoint’s header proxy runs through.

Where a marker comes from

A marker looks like a triviality — an empty type whose only job is to be distinct. Its real work is coordination, and the problem it coordinates is worth seeing first.

The problem: annotations with nowhere to live

Some formats need per-field instructions that the format itself demands. XML is the standard example: a value can be a child element, an attribute, or the element’s text, and nothing about a Rust struct says which. Serde has no slot for that kind of format-specific knowledge, so serde-based XML libraries smuggle it through the one field that is free-form — the name. Placement gets encoded as magic prefixes inside #[serde(rename = "...")], with each library choosing its own sigils.

The consequence is that the annotations on your types belong to one library, not to XML. Switching engines means rewriting every type, because the second library reads different magic. Types and codec end up welded together — precisely what a swappable codec is supposed to prevent.

The marker as the anchor

The wire model gives those annotations a real home — the binding’s extension bag — and gives that vocabulary an identity: the format marker. The marker and its extension keys together are the contract. capiw_ext_xml doesn’t just say “this is XML”; it defines what XML annotations mean and provides the typed readers a codec uses to read them back.

Any library that wants to speak the format links against that marker crate and honors that vocabulary. Two independently written XML engines that both build on capiw_ext_xml are genuinely interchangeable: swapping one for the other is a one-line change to the codec constructor, and not one annotation on your types moves. The marker is what makes “swap the engine” mean something.

“Honors that vocabulary” is testable, and the family tests it. Each format’s conformance suite lives in the codec that runs it — capiw_serde_json/tests/conformance.rs, capiw_quick_xml/tests/conformance.rs, capiw_prost/tests/conformance.rs — and pins the contract crate’s semantics: presence rules, the omit-on-None default, flattening, the extension attributes. A second engine for a format proves itself by carrying the same suite. The unpublished capiw_conformance_tests holds only the properties that need several codecs at once.

So a marker does two jobs at once. As a type parameter it keeps formats from mixing — a Codec<Xml> cannot satisfy a Codec<Json> boundary. As a contract it lets separate implementations agree on what a format’s annotations mean.

Who defines them

Anyone. Format is unsealed and empty, so a marker is one line in your own crate, and there is no registry to petition and no blessed list. Capi ships four — Json, UrlForm, Xml, and Protobuf — which is enough to develop the mechanism and exercise it against real formats. They carry no special status beyond being first.

That openness also covers a format outgrowing its own vocabulary. Because markers are ordinary types, a new one coexists with the old rather than replacing it: if a future revision of a format needed annotations the current contract can’t express, it would ship as its own marker with an enriched vocabulary, and both would remain valid at once. Types and codecs migrate when they choose to; nothing breaks on the day the new contract appears. A versioned format is the natural case for this — a hypothetical Protobuf4 alongside Protobuf rather than in place of it.

One marker per format, please

The openness has a failure mode worth naming, because it is the community’s to avoid rather than the compiler’s.

If three crates each define their own Json marker, the compiler sees three unrelated formats. A Codec<TheirJson> will not satisfy a Codec<OurJson> boundary even though both are, in every way that matters, JSON. The mechanism that was meant to catch a real mistake — handing XML to a JSON slot — instead splits an ecosystem into incompatible islands.

So the convention is simple: one marker per format, depended on rather than duplicated. If a contract crate for your format exists, build on it. Mint a new marker when the vocabulary genuinely differs — a new format, or a version needing annotations the existing contract can’t express — not to avoid taking a dependency.

If you do end up holding a handle tagged with a rival marker, assume::<F>() re-tags it, and how well that works depends entirely on the format. For one with no annotation vocabulary at all, the assertion is nearly always true — a JSON engine is a JSON engine, and a re-tagged handle will behave. For XML or protobuf, where the engine must read specific extension keys to function, re-tagging a codec that doesn’t honor them compiles cleanly and then misbehaves at runtime. assume is a repair tool for a fragmentation that shouldn’t have happened; converging on one marker is the actual fix.

The object-safe ABI

Skip this section on a first read; it is the “why” under the hood, not something you call.

Runtime format selection needs the byte engine to be an object — a &'static dyn WireFormat — because the whole point is that the config, not the compiler, picks the format. Rust makes that awkward for a reason worth understanding, because it is exactly why Serde’s traits could not be used here directly.

A trait with a generic method is not object-safe: the compiler can’t build one vtable covering every monomorphization. Serde’s core trait has exactly that shape:

pub trait Serialize {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error>;
}

serialize is generic over S, so dyn Serialize is illegal and you cannot store “something serializable” behind a trait object. The wire model defines its own object-safe split instead:

pub trait ByteEncode {                                   // erase a value → bytes
    fn encode_u8(&self, value: &dyn DynEncode) -> Result<Vec<u8>, CodecError>;
    fn encode_into(&self, value: &dyn DynEncode, out: &mut Vec<u8>) -> Result<(), CodecError> { … }  // provided
}
pub trait ByteDecode {                                   // hand back an erased decoder over bytes
    fn decoder<'de>(&self, bytes: &'de [u8]) -> Result<Box<dyn ErasedDecoder<'de> + 'de>, CodecError>;
}

// The object-safe principal a `Codec` points at, blanket-combined over the two halves:
pub trait WireFormat: ByteEncode + ByteDecode + Send + Sync {}

The generic-method problem is dissolved rather than wrapped: DynEncode is the object-safe counterpart to the generic WireEncode, ErasedDecoder the counterpart to WireDecode, and the dynamic dispatch happens inside those trait objects’ methods while the Codec handle itself stays entirely static. There is no erased_serde and no unsafe anywhere on this path. The only cost is a vtable call per operation and one heap allocation for the boxed decoder — negligible next to an HTTP round trip.

This is also why the two planes coexist. WireEncode/WireDecode are generic, so the typed path monomorphizes with no dynamic dispatch; the erased path exists for the runtime-selected handle. A type derives once and serves both.

The accessor convention

A config exposes its codecs through hand-written accessors, by convention named json_lib() / form_lib() / xml_lib():

impl GrokConfig {
    pub fn json_lib(&self) -> Codec<Json>    { self.json }
    pub fn form_lib(&self) -> Codec<UrlForm> { self.form }
}

These are not methods on the core ApiConfig trait — a client that speaks only JSON has no reason to answer xml_lib(), and a codec-free client has no codecs at all. Each config declares exactly the accessors its endpoints need. Endpoint and decoder code then reads them at the point of use: config.json_lib().encode_to_vec(...), config.json_lib().decode_from_slice(...). Building that config — the two-constructor pattern (new(<codecs>) for swappability vs. new_with_defaults() for batteries included) you can already see in the GrokConfig above — is the subject of the config chapter.

Next: how a type tells a codec what it looks like — Describing a Type.

Describing a Type

A type joins the wire model with three derives. This chapter covers what each one emits, the Binding they produce, and the two attribute families every type needs — naming and presence.

use capi_wire_model::{WireDecode, WireEncode, WireModel};

#[derive(WireModel, WireEncode, WireDecode)]
struct Shipment {
    id: String,
    weight_grams: u32,
}

The derives come from capi_wire_model, which a client crate depends on directly and enables with its derive feature.

The three derives

They divide cleanly — three derives, one description, plus the shape and the redaction the description implies:

  • WireModel emits a const BINDING: &'static Binding — a static description of the type’s wire shape, the single source of truth a codec reads; tooling can read T::BINDING to learn a type’s shape without instantiating it. It also emits two impls the description implies: WireShape, the recursive Shape every field binding stores for its type, and Redact, the typed redaction transform that replaces the model’s sensitive fields with placeholders. The Redact emission has a compile-time consequence: every non-skip field’s type must implement Redact — derived models, the primitives, Option, Vec, BTreeMap<String, _>, Tristate, Bytes, and the datetime and decimal types all do, and a hand-written leaf adds two identity methods (Value Classes and Redaction).
  • WireEncode implements fn encode<E: Encoder>(&self, encoder: E) — the value pushes itself into whatever encoder a codec provides.
  • WireDecode<'de> is the inverse, building a T from a decoder.

WireEncode/WireDecode reference <Self as WireModel>::BINDING, so a type that derives either must also derive WireModel — the one exception being #[wire(scalar)], which carries no binding. You normally derive all three together, and derive only WireEncode for a request type that never comes back or only WireDecode for a response type you never send.

The derives accept named-field structs, single-field tuple structs (transparent newtypes), and enums in four tagging modes.

What the Binding holds

The binding is the whole point of the design, so it’s worth seeing:

pub struct Binding {
    pub name: &'static str,                        // the type's wire name
    pub envelope: Wrap,                            // struct-level wrap/unwrap policy
    pub fields: &'static [FieldBinding],           // in declaration order; empty for an enum
    pub variants: &'static [VariantBinding],       // non-empty marks this an enum
    pub tagging: Tagging,                          // how variants are tagged
    pub unknown_variant: UnknownPolicy,            // what to do with an unrecognized tag
    pub tag_field: Option<&'static FieldBinding>,  // discriminator, for internal/adjacent
    pub content_field: Option<&'static FieldBinding>,
    pub ext: &'static [(&'static str, ExtVal)],    // format-extension knobs
    pub constants: &'static [ConstantBinding],     // wire entries emitted from literals, no field behind them
}

Each entry in fields is a FieldBinding — the table a codec and the walker actually read, one row per field:

pub struct FieldBinding {
    pub rust_name: &'static str,   // for error paths, and the default wire name source
    pub wire: WireName,            // the encode name plus any decode-only aliases
    pub wrap: Wrap,                // per-field wrap/unwrap policy
    pub scalar: Option<ScalarRule>,// the leaf rendering rule, when the field is a scalar
    pub presence: Presence,        // whether the field must be present
    pub shape: Shape,              // the recursive wire shape of the field's type
    pub class: ValueClass,         // Plain, Volatile, or Sensitive(Mask)
    pub flatten: bool,             // spliced into the parent rather than nested
    pub ext: &'static [(&'static str, ExtVal)],
}

A VariantBinding carries the same for each enum variant, constants included.

Everything is &'static — the binding is a table baked into the binary, not something built at runtime. That is what makes it readable without a value, which is what makes non-self-describing formats work. The ext bag at the end is the format-extension mechanism.

For a non-generic type the derive emits a shared static; for a generic one it emits a per-instantiation associated const, because a plain static cannot name type parameters.

Two macros, one type

Request and response types often sit between two independent macros, and keeping them straight avoids most early confusion:

  • #[capi] is ergonomics only — it generates a constructor and setters so callers build a value fluently. It adds nothing to the wire mapping.
  • #[derive(WireModel, WireEncode, WireDecode)] is the wire mapping. Every #[wire(...)] attribute in this part tunes that.

A type often carries both. They don’t interact — one shapes the Rust API, the other shapes the wire.

#[capi]
#[derive(Debug, Clone, WireModel, WireEncode)]
pub struct CreateShipment {
    pub address: String,           // required → a `new` parameter
    pub weight_grams: Option<u32>, // optional → a `.weight_grams(...)` setter
    pub tags: Vec<String>,         // optional → a `.tags(...)` setter
}

// caller:
let s = CreateShipment::new("12 Main St")
    .weight_grams(500)
    .tags(["express", "insured"]);

#[capi] also emits a rustdoc # Parameters section documenting each field, picking up the wire contract so readers of the model never have to guess. A field opts out of that documentation with #[wire(doc = false)], which has no wire effect.

Argument types follow one rule. Primitives — the integer and float types, bool and char — appear as themselves, so a bare literal infers: weight_grams(500). Every other type is taken as impl Into<T>, which is what lets new("12 Main St") hand a &str to a String field. A Vec<T> field accepts any iterator whose items convert Into<T> (the IntoVec<T> blanket), which is why .tags(["express", "insured"]) reaches a Vec<String>; a Vec of a primitive takes an iterator of that primitive.

That’s the entire builder story; it’s deliberately dumb, so it never fights the wire mapping.

Naming

#[derive(WireModel, WireEncode, WireDecode)]
#[wire(rename_all = "camelCase")]        // container: recase every field
pub struct Query {
    #[wire(rename = "type")]             // field: an exact wire name
    pub kind: String,
    pub r#type: String,                  // a raw identifier contributes its bare name, "type"

    #[wire(alias = "max", alias = "cap")] // field: extra names accepted on decode only
    pub limit: u32,
}
  • rename sets an exact wire name, on the container (the type’s own wire name) or a field. A reserved word needs no rename: a raw identifier (r#type) contributes its bare name, type, the way it resolves in Rust.
  • rename_all recases field names that lack an explicit rename. Accepts lowercase, UPPERCASE, camelCase, PascalCase, snake_case, SCREAMING_SNAKE_CASE, kebab-case, SCREAMING-KEBAB-CASE. On an enum it recases variant tags, not their fields.
  • alias (repeatable) adds names accepted on decode; encode always writes the canonical name. This is the tool for an API that renamed a field but still sends the old one. Canonical names and aliases must be distinct across the struct.

Presence: required, optional, skipped

Decoding fails with a missing-field error naming the wire key unless a field is optional. A field is optional when it is an Option, carries default, or is marked optional/tristate:

pub struct Query {
    pub kind: String,                       // required

    pub limit: Option<u32>,                 // optional: absent → None

    #[wire(default)]                        // optional: absent → Default::default()
    pub page: u32,

    #[wire(default = "default_page_size")]  // optional: absent → the named function
    pub size: u32,

    #[wire(skip)]                           // never on the wire, absent from the binding
    pub internal: Cache,
}
  • default makes a field optional on the wire and fills an absent one from Default::default() or a named function. On an Option field, default = "path" fills the absent case from the path rather than collapsing to None.
  • skip removes the field from the wire and from the binding entirely; it is produced from its default on decode. Use it for cached or derived state that has no business on the wire.

Omitting a field on encode is a separate question. The default needs no attribute: a plain Option<T> field omits its key when it is None. The other structure-shaping attributes are in Shaping Structures: skip_encoding_if for a predicate, and optional/tristate for the richer presence models.

Transparent newtypes

A single-field tuple struct is transparent: its wire form is the inner value’s, and its encode/decode delegate straight through.

#[derive(WireModel, WireEncode, WireDecode)]
pub struct Rate(u64);      // on the wire this is just a number

Because it has no fields of its own, it accepts only rename, ext, bound, and crate; its binding is fieldless and carries a ("transparent", true) ext marker so tooling can recognize it. This is the cheapest way to give a primitive a domain type without changing the wire at all.

Entries with no field

A wire object sometimes needs a member no Rust field should carry — JSON-RPC’s "jsonrpc": "2.0", OData’s @odata.type beside a typed value. #[wire(constant(...))] emits such an entry from a literal: on a container, #[wire(constant("jsonrpc" = "2.0"))] names the entry outright and it leads the object; on a field, #[wire(constant(suffix = "@odata.type", value = "Edm.Int64"))] derives the name from the host field and rides its presence. Constants are encode-only and never members of fields — on decode the key is an unknown one, handled as the type already handles those — and they populate the binding’s constants slice for introspection. Shaping Structures has the rules.

Container attributes the derive also takes

Three more container attributes rarely appear on a type and matter when they do: bound = "…" (or bound(encode = "…", decode = "…")) adds where-clauses the derive cannot infer for a generic type; crate = "path" renames the wire-model crate the generated code refers to; and datetime_crate = "path" does the same for the datetime carriers. Hand-Written Impls and the Value Tree covers the generic and crate-path cases.

Next: Shaping Structures — what to do when the wire shape and the Rust shape don’t line up.

Shaping Structures

The previous chapter assumed the Rust shape and the wire shape line up. Often they don’t: the payload is buried under a wrapper key, a nested struct is spliced flat into its parent, or a field needs a conversion no derive can guess. These attributes close that gap — and the last one, tri-state, is the answer to PATCH.

Envelopes: unwrap and wrap

Two different jobs, easy to confuse.

unwrap is a container attribute: the struct’s own fields live under a wrapper key.

#[wire(unwrap = "data")]
pub struct Page { pub items: Vec<Item>, pub next: Option<String> }
// wire: { "data": { "items": [...] } }   // a None `next` is omitted

wrap is a field attribute: this one value sits under a key while Rust holds it bare.

#[wire(wrap = "content")] pub body: Msg,        // { "content": <body> }
#[wire(wrap_each = "url")] pub urls: Vec<Url>,  // [{ "url": <element> }, ...]

Both wrap and wrap_each are repeatable, and several wraps nest outermost-first. wrap_each applies to each element of a Vec or Option<Vec>. The two are not combinable with each other, and neither combines with skip, flatten, or joined.

flatten

flatten does three distinct things depending on the field type.

Splicing a struct lifts a child’s fields into the parent object:

pub struct Request {
    pub id: String,
    #[wire(flatten)] pub paging: Paging,   // { "id": ..., "limit": ..., "offset": ... }
}

Splicing an externally tagged enum lifts the variant’s key beside the parent’s own keys — the shape a protobuf oneof takes when it is transcoded to JSON:

#[wire(rename_all = "camelCase")]
pub enum Target { PlaceId(String), Address(String) }

#[wire(rename_all = "camelCase")]
pub struct Waypoint {
    #[wire(flatten)] pub target: Option<Target>,   // { "placeId": "…", "via": true }
    pub via: Option<bool>,
}

Exactly one of the variant keys may be present on decode; two is an error, and none decodes the Option as None. An untagged enum has no key to splice and is refused.

Collecting unmatched keys turns one string-keyed map into the catch-all for everything the parent didn’t match:

pub struct Response {
    pub id: String,
    #[wire(flatten)] pub extra: BTreeMap<String, Value>,  // every unrecognized key lands here
}

The rules are strict, because the forms claim the same territory. flatten is rejected on sequence, scalar, Option<Map>, and skip fields; you get at most one flatten map, and not alongside a flattened struct. An Option<Struct> or Option<Enum> field flattens as splice-or-omit — Some splices, None contributes nothing, and decode yields None when no claimed key is present. A flattened child’s field names must be disjoint from the parent’s and from any other flattened child’s, and a flattened struct must not declare its own unwrap envelope.

via: proxy conversions

via is the general escape hatch for a field whose wire shape no attribute expresses: route it through a proxy type that can.

#[wire(via = "MyProxy")] pub odd: Weird,

The contract is TryFrom in both directions — Proxy: TryFrom<FieldType> on encode, FieldType: TryFrom<Proxy> on decode. An infallible From/Into pair satisfies it through std’s blanket impls, so a simple conversion needs no error type. via composes with an outer Option, and is rejected on sequence fields and alongside skip, flatten, wrap/wrap_each, a scalar rule, optional, or tristate.

If you know serde_with, this is the same idea with a smaller surface.

Omitting on encode

The default comes first: a plain Option<T> field omits its key on encode when it is None, with no attribute at all. The tools below are for everything else, narrowest first:

#[wire(skip_if_none)]                              // container: accepted, and redundant
pub struct Patch {
    #[wire(skip_encoding_if = "Vec::is_empty")]    // field: omit when the predicate says so
    pub tags: Vec<String>,
}
  • skip_if_none is accepted and changes nothing: None already omits. A field-level predicate takes precedence over it.
  • skip_encoding_if names a fn(&FieldType) -> bool. Decode is unaffected, so a non-Option field omitted this way needs #[wire(default)] to decode back. Struct fields only; not combinable with skip or flatten.

Tri-state and optional: the PATCH model

A PATCH body has to distinguish three states per field: leave unchanged, set to a value, or explicitly clear to null. A plain Option only has two. That’s Tristate<T> (Keep / Set(T) / Clear) with #[wire(tristate)]:

#[capi]
#[derive(WireModel, WireEncode)]
pub struct PatchCustomVoiceRequest {
    #[wire(tristate)] pub name: Tristate<String>,
    #[wire(tristate)] pub description: Tristate<String>,
}
StateOn the wireMeaning
Keep (the Default)omitted entirelyleave the server’s value alone
Set(v)the valueset it
Clearexplicit nullclear it

On decode the two absent-ish cases stay distinct: an absent field yields Default (Keep), while a present null goes through the field type’s WireDecode.

#[wire(optional)] is the two-state sibling for a non-PATCH sparse body: the field type’s WireOptional impl decides value-vs-omit on encode, and on decode both absent and — leniently — a present null yield the default. Two types carry the impl: Option<T>, where None omits (which a plain Option field already does without the attribute), and String, where an empty string omits — so the attribute’s distinctive work is the empty-string case and any wrapper type of your own that implements WireOptional. It composes with default and wrap.

The two are mutually exclusive, neither combines with skip, flatten, or skip_encoding_if, and both are struct-only (enum variant fields reject all three). tristate additionally rejects default — its absent state is always its own Default — and wrap/wrap_each.

Entries with no field

Some wire objects carry a member that describes the message rather than the model — JSON-RPC’s "jsonrpc": "2.0", OData’s @odata.type annotation beside a typed value — and a Rust field for it would be a field every constructor has to fill with the same literal. #[wire(constant(...))] emits the entry from the literal instead:

#[derive(WireModel, WireEncode, WireDecode)]
#[wire(constant("jsonrpc" = "2.0"))]                  // container: leads the object
pub struct Call {
    pub method: String,
    #[wire(constant(suffix = "@odata.type", value = "Edm.Int64"))]  // field: rides its host
    pub count: Option<u64>,                            // { "count@odata.type": "Edm.Int64", "count": 3 }
}

A container constant names its entry outright, on a struct or an enum variant, and is emitted before the container’s own members. A field constant derives its name from the host field — the host’s wire name plus the suffix — is emitted immediately after the host, and only on the paths where the host emits a member: an omitted None host omits its constant, a tristate Clear keeps it. rename_all never touches a constant’s name. The value grammar is the four scalar lanes — string, integer, float, boolean — which every format renders without nesting, an XML attribute and a urlencoded pair included; a field-numbered format (protobuf) refuses a constant exactly as it refuses an unnumbered field.

A constant is encode-only. It is never a member of fields, so on decode the key is an unknown one, handled as the type already handles those, and never verified against the declared value: the type’s own fields are the contract, and a service that changed "jsonrpc" has changed its protocol. The literal is source, so a constant is never sensitive and never volatile.

What a field can be

The model implements the wire traits for a closed set of leaf and container types, and a field is one of them, a derived model, or a type with its own impls (escape hatches):

KindTypes
booleans and numbersbool; i8i64, i128, u8u64, u128; f32, f64 — no usize/isize, whose width is the platform’s
text and bytesstr, String; Bytes — a byte string, rendered by a base64/base64url/hex scalar rule in text formats and as an array of byte values when no rule is set (Vec<u8> is a sequence of integers)
presenceOption<T>, Tristate<T>
containersVec<T>; BTreeMap<String, V> — the only map, so encoded objects are canonical
carriersthe datetime and decimal types, Duration under a duration rule

There are no impls for tuples, arrays, or HashMap: a positional tuple has no wire name to encode under, and a hash map’s iteration order would make the same value encode differently between runs. A Vec<(K, V)> or a BTreeMap covers the map case; a newtype or a small struct covers the rest.

Next: Scalars, Numbers, and Money — the leaf-level rules.

Scalars, Numbers, and Money

Leaf values are where wire bugs hide: a number quoted as a string, bytes that should have been base64, money rounded through a float. The wire model handles each with a scalar rule — one per field, declared on the field, checked against its type at derive time.

Numbers

Some APIs send numbers as JSON strings ("1736380800"). Model the field as its real numeric type and annotate the wire form; never change the Rust type to String to match the wire.

#[wire(number(as = "string"))] pub amount_cents: i64,   // reads/writes "12345"

number(...) takes three knobs:

KnobEffect
as = "string" / as = "number"a quoted string on the wire, or the native number lane (the default)
scale = Na fixed decimal scale
rounding = "..."how a precision-dropping encode rounds: half_even (the default), half_up, half_down, up, down, ceil, floor, or unnecessary, which refuses a value that would need rounding

Bytes

A Bytes field renders as an encoded string with one of three rules:

#[wire(base64)]    pub data: Bytes,
#[wire(base64url)] pub token: Bytes,
#[wire(hex)]       pub digest: Bytes,

The distinction between Bytes and Vec<u8> matters and is easy to trip over: in the wire model’s core shape, Vec<u8> is a repeated integer — a JSON array of numbers, and in protobuf a repeated field. Bytes is a genuine byte string. Reach for Bytes whenever the value is opaque binary data — and give it a rule: with none set, a text codec renders a Bytes field as an array of byte values, which is rarely what the service wants. Sentinel mapping applies to leaves of every kind: none_when turns a service’s -1 or "" into None on a numeric or string field as readily as on a date — the datetime chapter shows it.

Joined lists

A Vec of scalar leaves can render as one separator-joined string:

#[wire(joined = ",")] pub kinds: Vec<Kind>,   // kinds=a,b,c

The separator is a single character, it must not appear inside an element, and a Vec containing a single empty string is rejected on encode. This is common in header and query values — for example a typed field mask:

#[endpoint(location(header))]
#[wire(rename = "x-goog-fieldmask", joined = ",")]
pub field_mask: Vec<FieldMask<SearchNearbyResponse>>,

FieldMask<T> (from google_places) is a phantom-typed newtypeFieldMask<T>(Cow<'static, str>, PhantomData<fn() -> T>) — so a field mask is tied at compile time to the response type it selects into, catching a mask that names a field the response doesn’t have. The #[endpoint(location(header))] half is endpoint routing, covered in the endpoint contract; the joined half is the wire rule.

Money and decimals

Never model money as f64. Two supported approaches:

Integer minor units. An i64 count of cents plus number(as = "string") when the API quotes it. This is what grok does, and it needs no extra types.

The decimal carriers. capi_wire_decimal provides two types that derive the wire traits and just work as field types — no #[wire] key needed:

TypeShape
Decimalsigned 128-bit coefficient with a per-value power-of-ten exponent; any scale, exact to ~38 significant digits
FixedDecimal<const SCALE: u8>the same with the scale pinned in the type — FixedDecimal<2> for a two-place money field, the way a SQL NUMERIC(_, s) column fixes it

Both parse, format, and rescale but do no arithmetic. That is deliberate: Rust has no core decimal type, and hard-coding a third-party one into an API client would tie every downstream user to that choice. Instead they are carriers that know how to cross the wire, and a third-party decimal crate plugs in by implementing ToWireDecimal/FromWireDecimal for its own type. The framework never depends on a decimal-math library, and a value still round-trips exactly.

Both encode on the raw-number lane, so through a JSON codec a decimal is a bare number preserved to the digit; add #[wire(number(as = "string"))] to send it quoted instead.

Value classes

Two more attributes say what kind of value a field holds rather than how it is spelled: #[wire(sensitive)] for a secret and #[wire(volatile)] for a value that varies run to run. They have no effect on the bytes a codec writes — they are properties recorded in the binding — but they drive the recorder’s masking, the placeholder a fixture stores, and the typed redaction transform a request goes through to be born without its secrets. Value Classes and Redaction is the whole story.

Where the scalar rules stop

One scalar rule per field, and the datetime families own their field’s leaf conversion outright — so #[wire(timestamp(...))] and friends do not combine with number, base64, or joined. They are the next chapter, and they are the largest attribute family in the model for a good reason: a silent wrong unit or offset is the costliest wire bug there is.

For a leaf whose format no rule expresses, write a WireScalar type or route the field through a via proxy.

Dates, Times, and Durations

The rule, stated once: the field type carries the semantics; the attribute carries the contract. You reason about five value kinds — Timestamp, DateTime, Date, Time, and core::time::Duration — and every “what do I actually put on the wire, and what do I assume off it?” question is answered by the #[wire(...)] annotation written on the field, once, machine-readably. #[capi] surfaces that contract into the field’s rustdoc so readers of the model never have to guess; a field opts out of that generated line with #[wire(doc = false)], which has no wire effect.

Why datetimes get this much machinery

This is by far the largest attribute family in the wire model, and the size is worth explaining before you wade into it.

Dates and timestamps appear in nearly every API, and almost no two services agree on how to write one. Epoch seconds, or millis, or the same number quoted as a string. RFC 3339, sometimes insisting on a trailing Z. A bespoke %m/%d/%Y. A field called a date that is really a UTC-midnight instant. An empty string that means null. None of this is exotic — it is an ordinary Tuesday’s worth of API integration.

Today that variety lands on whoever is integrating. You read the documentation if it covers the question, and when it doesn’t — which is often — you find out by trial and error against a live service, or by staring at a sample payload and guessing. The knowledge exists, but it lives in someone’s head or in a thread somewhere, not in the code.

Capi’s premise is that this knowledge should be written down once, by the person best positioned to have it. A client library’s author works intimately with the service: they already know it sends epoch millis, or wants a Z, or encodes “no end date” as 9999-12-31. The attribute families are where they record it — on the field, machine-readably, one time.

Everyone downstream then gets it for free. A consumer of the client works with Timestamp and Date and the right bytes go out; they never have to learn the service’s conventions, because the conventions are already in the type. And if they do want to know, the answer is not buried — the annotation sits on the endpoint’s field, and #[capi] lifts it into the field’s rustdoc, so the wire contract is legible without reading the source.

So read the length of this chapter correctly: it is a menu for client authors, not a burden on the people who use their clients. If you are consuming a client rather than writing one, you can comfortably stop here. If you are writing one, the rest of the chapter is the vocabulary for saying exactly what your service does — and most of its design exists to make a silent wrong unit or offset, the costliest mistake in API integration, impossible to write by accident.

The five kinds

The value isField typeBare default
an instant on the timelineTimestampnone — the attribute is required
a civil date + clock + fixed offsetDateTimeRFC 3339
a calendar date, zone-freeDateISO 8601 (YYYY-MM-DD)
a clock time, zone-freeTimeISO 8601 (HH:MM:SS)
a spancore::time::Durationnone — the attribute is required

Timestamp and Duration deliberately have no default wire form: neither has a universal convention, and a silent wrong unit is the costliest mistake in API integration. An unannotated field of those types does not compile.

/// Creation time (RFC 3339 — the bare default).
pub create_time: Option<DateTime>,

/// Unix creation time.
#[wire(timestamp(format = "epoch_seconds"))]
pub created: Timestamp,

/// Token lifetime in whole seconds.
#[wire(duration(unit = "seconds"))]
pub expires_in: Duration,

Formats

Each family accepts format = "...": the named formats (rfc3339, iso8601, httpdate, and — for timestamps — epoch_seconds / epoch_millis / epoch_micros / epoch_nanos) keep hand-tuned parsers; anything else is a %-string over the directive set %Y %m %d %e %B %b %a %H %M %S %.f %:z %z. Durations take unit = "seconds" | "millis" | "micros" | "nanos" for numeric wires or format = "iso8601" (PT1H30M), format = "go" (1h30m0s), or format = "protobuf" (3.5s, the google.protobuf.Duration JSON mapping a gRPC-transcoded API speaks).

Format strings compile inside the derive: an unsupported directive or a kind-mismatched format is a compile error with a span on the attribute. The kind checks are strict — a date format rejects clock directives, a time format rejects date directives, and an instant kind requires at least year, month, day, and hour. An instant field never decodes from date-only text; an API that sends a bare date for something instant-like is modeled as Date with the midnight bridge (below), which forces the convention to be written down.

#[wire(timestamp(format = "%Y-%m-%d %H:%M:%S", assume = "utc"))]
pub processed: Timestamp,

#[wire(date(format = "%m/%d/%Y"))]
pub due: Date,

#[wire(time(format = "%H:%M"))]
pub opens_at: Time,

The knobs

Every knob answers a question a real API forces.

  • as = "string" — quoted epoch/duration numbers: "1736380800".
  • assume = "utc" | "+05:30" — the offset convention of an offsetless textual format, applied symmetrically: encode converts the instant to the assumed offset’s civil time before formatting; decode attaches the offset after parsing. Required whenever an instant kind uses a format with no offset directive. "local" is deliberately not a value — in a client library “whose local?” is a question, not an answer.
  • convert = "utc" | "+05:30" — encode-side offset normalization for offset-carrying formats (APIs that require Z). For Timestamp string formats the default is UTC; for DateTime the default is the value’s own offset.
  • subsec = 0..=9 — encode-side fractional-second digits (decode always accepts any). Composes with epoch formats: timestamp(format = "epoch_seconds", subsec = 6) emits 1355517523.000005 as an exact token — no float in the middle. Default: as needed (shortest, omitting a zero fraction).
  • rounding = "floor" | "half_even" | ... | "unnecessary" — how precision-dropping encodes round (nanos → epoch seconds, subsec truncation). Default floor — time truncates by convention. "unnecessary" errors instead of silently losing precision.
  • accept = "..." (repeatable) — alternate formats honored on decode only. Encode always uses format; decode tries format first, then each accept in order.
#[wire(timestamp(format = "rfc3339", accept = "epoch_seconds"))]
pub seen_at: Timestamp,

#[wire(datetime(format = "rfc3339", convert = "utc", subsec = 3))]
pub modified: DateTime,

The midnight bridge

Some services store calendar dates as UTC-midnight instants and serialize them as full datetimes. Model the field as what it means — a Date — and declare the convention:

/// The service stores hire dates as UTC-midnight instants.
#[wire(date(format = "rfc3339", midnight = "utc"))]
pub hire_date: Date,

Encode renders midnight at the declared offset (2024-03-01T00:00:00Z). Decode policy is on_decode:

on_decodeReading 2024-02-29T19:00:00-05:00 (= 2024-03-01T00:00:00Z)
"normalize" (default)it is an instant: convert to the declared offset, take the date → 2024-03-01
"written"the civil date as printed → 2024-02-29 — “the date the user saw”, and the fallback for DST-aware-local-midnight services
"strict"require exactly midnight at the declared offset → loud decode error on drift

Sentinels: none_when

Some services encode absence in-band — an empty string for a datetime, -1 for a count, a real-looking 9999-12-31 for “no end date”. The wire-level #[wire(none_when = "...")] attribute (repeatable, on an Option scalar-leaf field of any kind — datetimes, numbers, strings) maps such sentinels to None on decode; None encodes as the first listed sentinel (unless the field is omission-guarded, in which case omission wins).

Literals compare against the raw wire token before parsing. Named @ sentinels are the industry’s sentinel dates — values that parse as perfectly valid dates, where the name carries the meaning and the framework carries the date:

NameMatchesOrigin
@zero-date0000-00-00 (and the datetime form)MySQL zero date
@epochthe exact instant 1970-01-01T00:00:00Zepoch-zero defaulting
@end-of-timedate 9999-12-31, any clockSQL “no end date”
@smalldatetime-maxdate 2079-06-06, any clockSQL Server smalldatetime ceiling
@sqlserver-mindate 1753-01-01, any clockSQL Server datetime floor
@ole-zerodate 1899-12-30, any clockOLE Automation day zero
/// Completion time; the service sends "" while the job is running.
#[wire(datetime(format = "rfc3339"), none_when = "")]
pub completed_at: Option<DateTime>,

/// "No end date" arrives as a real-looking 9999-12-31.
#[wire(date, none_when = "@end-of-time")]
pub valid_until: Option<Date>,

Named sentinels match the civil date as written, regardless of time-of-day or offset — servers are sloppy about the clock part of a sentinel. Escape a literal @ as @@.

The value types at runtime

Values are always valid: construction and parsing are eager.

let d = Date::new(2026, 3, 7)?;
let dt = DateTime::parse_rfc3339("2024-06-15T12:30:45+05:30")?;
let ts = Timestamp::from_second(1_736_380_800)?;
let t = Time::parse_strftime("%H:%M", "09:30")?;
  • The calendar is proleptic Gregorian, years -9999..=9999; instants are i128 Unix nanoseconds; leap seconds do not exist (a wire 23:59:60 clamps to 59 on parse and is never emitted).
  • Offsets are fixed — a TimeZone is a whole-second offset east of UTC (TimeZone::UTC, from_fixed_offset_secs), read back through DateTime::time_zone and re-attached with with_time_zone; no tzdb, no DST. Zone-aware logic belongs in the application, converted at the boundary via the interop features: jiff, chrono, and time are symmetric optional features that add From/TryFrom conversions and nothing else. No feature anywhere in a dependency graph changes another crate’s behavior.
  • The crate’s other features are the platform pair: std (the default) backs Timestamp::now and DateTime::now_utc with the system clock, and on browser wasm its js capability reads the browser’s clock and normalizes JS-flavoured date values.
  • DateTime compares by instant; it also offers parse_http_date / format_http_date and the flexible parse_iso8601 (calendar, week, and ordinal forms). Date, DateTime, and Time have parse_strftime / format_strftime; Timestamp is an instant with no calendar form of its own, so it does not.
  • The duration module parses and formats ISO 8601, Go, and protobuf duration text; the calendar module exposes the civil conversions (days_from_civil / civil_from_days, days_in_month) the whole framework shares.
  • capi_time, in the Capi framework workspace, is a different thing: the framework’s clock and sleep layer (Instant, SystemTime, ApiClock) that the request context, rate limiters, and retries run on. It is not a wire type and never appears in a model.

The calendar engine is hand-rolled and oracle-verified: a differential suite compares it against jiff over all 7,304,484 days in years −9999..=9999 in CI, while jiff itself stays a dev-dependency.

Escape hatch

A genuinely weird format is a custom WireScalar type or #[wire(via = ...)] — the same extensibility story as the rest of the framework. The attribute families are the front door, not the only door.

Next: Enums on the Wire.

Enums on the Wire

Enums are where wire formats disagree most, so the model supports four tagging modes plus the two catch-all policies real APIs need. If you know Serde’s enum representations, these are the same four with the same names.

The four taggings

// external (the default) — no attribute needed
#[wire(tag = "type")]                    // internal: the tag rides inside the object
#[wire(tag = "type", content = "data")]  // adjacent: { "type": ..., "data": ... }
#[wire(untagged)]                        // no tag; matched by shape

External (the default): a fieldless variant is a bare wire string (Active"active"); a newtype variant is a single-key object ({ "circle": …CircleData… }); a struct variant is a single-key object holding its fields. Because the content is nested, any inner type works. Struct variants here must be on a non-generic enum — on a generic enum, wrap the struct in a newtype variant.

Internal (tag = "type"): each variant is an object carrying a discriminator field. A unit variant is { "type": "active" }; a newtype variant’s inner struct fields sit beside the tag ({ "type": "circle", …CircleData fields… }); an inline struct variant is { "type": "point", "x": …, "y": … }. The inner type of an internally-tagged newtype variant must be a struct with no unwrap envelope, and no content field may share the tag’s wire name — that collision is rejected at derive time.

Adjacent (tag = "type", content = "data"): the discriminator and the content are sibling fields. A unit variant is { "type": "active" } with no content key; a newtype variant is { "type": "circle", "data": …CircleData… }. On decode the two keys may appear in either order and other keys are ignored; a data variant missing its content key is an error. Adjacent struct variants are rejected — use a newtype variant wrapping a struct, or internal tagging.

Untagged: no discriminator at all. A newtype variant is just its inner value; a struct variant is a bare object of its fields. On decode the codec tries each variant in declaration order and the first that decodes the whole value wins — a variant matching only a prefix is rejected and the trial continues.

Untagged ordering is significant. Because unknown object keys are ignored, a struct variant whose required fields are a subset of a later variant’s will always win. Put the larger, more specific variant first. A variant’s own wire tag is unused here, so variant rename/alias and the container rename_all are rejected (a struct variant’s own field renames stay in effect), and untagged unit variants aren’t supported — model the absent case with Option.

A mixed enum is externally tagged with some variants marked #[wire(untagged)] individually: the untagged variants encode bare and are matched by shape, the tagged variants must be unit and are matched by their wire string, and the whole enum decodes through the untagged trial.

Open string enums

This is the common real case: a documented set of string values that the service will grow later. #[wire(string_enum)] plus a #[wire(other)] catch-all handles unknown values without failing:

#[derive(WireModel, WireEncode, WireDecode)]
#[wire(string_enum, rename_all = "snake_case")]
pub enum VoiceKind {
    Cloned,
    Preset,
    #[wire(other)]
    Other(String),   // any value the API adds later lands here, losslessly
}

string_enum means external tagging, no generics, fieldless variants, plus an optional Other(String). In exchange, WireModel also emits the string ergonomics sharing the variants’ wire strings: Display and FromStr (erroring on an unknown string when the enum is closed), plus infallible From<&str> / From<String> / From<&String> when the Other catch-all makes construction total.

The catch-all works in both directions

It’s natural to read Other(String) as a decode-side safety net, but the encode side matters just as much.

A service’s string vocabulary is usually a fixed set of sentinels, and turning them into real variants is the whole point: they’re discoverable, the compiler catches typos, and an editor can complete them. The hidden cost of a closed enum is that it also caps what you can send. When the service adds a value, you can’t send it — even knowing the exact string — until the client library ships an update and you upgrade.

Because Other(String) re-encodes the string it holds verbatim, that ceiling disappears. You can construct one for a value the client has never heard of and send it immediately:

let kind = VoiceKind::Other("experimental".into());  // goes out as "experimental"

This is also why the infallible From<&str>/From<String> conversions appear only when the catch-all is present: with it, every string is a valid value, so construction cannot fail.

Note that the encode half specifically needs the String-carrying form. A fieldless #[wire(other)] variant absorbs an unknown tag on decode but keeps nothing, so it can neither round-trip the original value nor express a new one on the way out.

The pattern gives you strongly typed values for the vocabulary you know about without making that vocabulary a limit: a client that survives the service adding a value on a Tuesday, and can use it the same afternoon.

Catch-alls: other vs capture

Two different policies for “something I don’t recognize.”

#[wire(other)] catches an unrecognized tag. A fieldless #[wire(other)] variant absorbs any unknown tag; without one, an unknown tag is a decode error. Other(String), which captures the unrecognized wire string itself, is supported only on an external-tagged enum whose other variants are all fieldless. It’s rejected on an untagged enum, takes no alias, and only one variant may carry it.

#[wire(capture)] catches an unrecognized value. It marks a newtype variant — e.g. Other(Value) — as the last-resort catch-all: any value no other variant matches is decoded whole and self-describingly into the variant’s inner value, then re-encoded transparently with no tag wrapper. The inner type must be able to absorb an arbitrary value, meaning a self-describing type such as capi_wire_value::Value — not a scalar, Option, or sequence.

Two things to know about capture. Its round trip is semantic, not byte-exact: the value re-emits as the codec’s canonical form, so original bytes survive only for already-canonical input, and numbers re-emit through the value model’s numeric laning. And it is reached only as a last resort — never by a wire tag, and on an untagged enum it’s held out of the trial loop entirely so concrete variants win regardless of order.

An enum may use other or capture, not both. Either policy also answers a missing tag: an internally or adjacently tagged object whose tag field is absent or null is an object the enum’s own vocabulary cannot name, so it resolves the way an unrecognized tag does — into the catch-all — and only an enum with no catch-all rejects it as an invalid envelope.

Flattening an externally tagged enum

An externally tagged enum field can be #[wire(flatten)]ed into its parent, so the variant’s key sits beside the parent’s own keys: { "placeId": "…", "via": true } rather than { "target": { "placeId": "…" }, "via": true }. That is the shape a protobuf oneof takes when a service transcodes it to JSON. Exactly one variant key may be present on decode, none decodes an Option<Enum> as None, and an untagged enum — which has no key to splice — is refused. Shaping Structures has the rules beside the other flatten forms.

Variant attributes

Variants take rename, alias (repeatable), other, capture, ext, a per-variant untagged, and constant("name" = value) where the variant renders a map of its own. The enum’s rename_all recases variant tags, not their fields. Variant tags and aliases must be distinct across the enum. Struct-variant fields accept the ordinary field attributes except skip_encoding_if, optional, and tristate.

A protobuf enum numbers its variants rather than naming them; that vocabulary (variant_number, open versus closed enums) belongs to the protobuf contract and is in the next chapter.

Next: Format Extensions — how a format adds vocabulary the core never knew about.

Value Classes and Redaction

Two attributes say what kind of value a field holds, and one of them — sensitive — is the start of a mechanism that runs through the whole family: the wire model’s typed redaction transform, Capi’s .redacted() on an endpoint, the seal’s placeholders for credentials and signatures, and the recorder’s twin. This chapter is the one place that mechanism is stated; the auth, signing, and testing chapters link here for it.

The classes

pub struct Session {
    #[wire(sensitive)] pub token: String,      // secret
    #[wire(volatile)]  pub request_id: String, // varies run to run
    pub account: String,                       // plain
}
ClassMeaningRecordedCompared
Plain (default)an ordinary valuein fullin full
volatilenon-deterministic — timestamps, nonces, request idsverbatimby presence and lane only
sensitivesecretmaskedby presence and lane only

The class is a property recorded in the field’s Binding — codecs and tooling read it there — and it is a statement about the field, not an instruction to any one consumer: say what the field is, and every consumer does the right thing with it. The header encoder honours sensitive by marking the emitted header value sensitive; the recorder masks it before a fixture is written and compares it by presence on replay; a comparison report substitutes the placeholder for sensitive descendants.

#[wire(skip)] fields belong to neither plane: they move verbatim, unmasked, which is the one loud way to keep a value out of both. sensitive combines with skip_encoding_if — the predicate may omit more than masking would.

The placeholder

One text stands in for every masked string: REDACTED_PLACEHOLDER, spelled [REDACTED]. It is what a fixture stores for a sensitive string lane, what a serialized header shows in place of a credential, what the seal writes where a signature would go, and what a failure report prints — one text, so a fixture, a log line, a report, and a scanner all name the same thing. The other lanes have their own constants: 0 for numbers, false for booleans, empty bytes for a byte field.

An author who wants something else says so inside the class:

#[wire(sensitive = "sk-[REDACTED]")]             pub key: String,     // a literal for the byte plane
#[wire(sensitive(mask = "sk-[REDACTED]"))]       pub key: String,     // the same, spelled long
#[wire(sensitive(redacted = "\"anon\""))]        pub account: String, // a typed placeholder expression
#[wire(sensitive(redacted_fn = "mask_email"))]   pub email: String,   // fn(&T) -> T, computed from the value

mask and the two redacted keys fill slots on different planes — the next section — and redacted / redacted_fn share one slot: a sensitive field has one typed placeholder. Nesting the keys inside sensitive(...) is what keeps a placeholder inseparable from the class it describes.

Two planes, chosen by direction

Two mechanisms mask a sensitive value, and which one runs is decided by the direction of the message, not by preference.

PlaneRuns whenReachesPlaceholder keys
Typed — the Redact transforma model exists before there are bytes: requestswhatever the model declaresredacted / redacted_fn
Byte — the recorder’s maskingonly bytes exist: responses, streams, undeclared secrets, non-model bodieswhatever the decoded tree holdsmask (= sensitive = "…")

A response arrives from the server as bytes. There is no model instance to call .redacted() on, and decoding to redact and re-encoding would replace the server’s actual bytes in the fixture — so the byte plane is the only mechanism on the response side, permanently. The typed plane owns the request side, where a request can be born redacted instead of scrubbed afterwards; it is also the only plane that can catch an undeclared secret, because a born-redacted request has nothing left to learn from.

The practical rule for choosing a key: on a request model, reach for redacted / redacted_fn, and a bare sensitive is usually enough; on a response model, reach for sensitive = "…" when the lane default will not do — the typed keys are inert there. A model used in both directions annotates for both, sensitive(mask = "XXX", redacted = "…"), and the two vocabularies never reinterpret each other.

The typed transform: Redact

capi_wire_model::Redact is the consuming transform on a typed value:

pub trait Redact: Sized {
    /// Plain position: keep the value, redact whatever it declares sensitive inside.
    fn redacted(self) -> Self;
    /// Sensitive position: the placeholder form, every leaf masked.
    fn redacted_placeholder(self) -> Self;
}

Two methods, because one cannot answer both questions about the same value: a plain String field must survive redaction untouched, while a sensitive one must become the placeholder. Both consume and return Self, so redaction is a move — nothing is cloned, and what comes out is the type that went in. Leaves are identity in plain position and the lane’s constant in sensitive position. Containers map, preserving presence and element count: a masked Some stays Some, because absence hides nothing, and a masked sequence keeps its length, because that is the shape its binding needs to decode again. A map’s keys are structure, not payload, and are left alone.

#[derive(WireModel)] emits a Redact impl for every model, recursing field-wise and replacing its own sensitive fields with their placeholders — which has a compile-time consequence: every non-skip field’s type must implement Redact, or the derive refuses. Derived models, the primitives, Option, Vec, BTreeMap<String, _>, Tristate, Bytes, and the datetime and decimal types all do; a scalar type of your own adds two identity methods when it holds nothing sensitive, or a real placeholder when it does (the escape hatches chapter shows the shape). A model that declares nothing sensitive redacts to itself, and a fieldless enum has no content to replace — most types need no redaction, and are their own placeholder.

Two types worth knowing the placeholders of: a FileBlob in sensitive position becomes an empty in-memory blob with its file name masked — the path or browser handle would not resolve on the machine reading a fixture back, and the name is often the most identifying thing about an upload — while its MIME type survives, because a multipart body whose parts lost their types cannot round-trip. And a number quoted as text masks to "0" on the byte plane (parseable) against the field’s own scale on the typed plane; the test framework inventories such divergences rather than hiding them.

The framework’s verb: ApiRedact

#[wire(...)] belongs to the wire model, and Capi never parses it. What the framework owes its users is a way to name the redaction step as part of the framework, uniformly for every endpoint. ApiRedact, reachable through the prelude, is that name:

let twin = endpoint.clone().redacted();          // plain position
let placeholder = endpoint.clone().redacted_placeholder();

A blanket impl lifts every Redact implementor onto it at zero authoring cost, so an endpoint that derives WireModel beside ApiEndpoint gets it for every routed field — path fields included, so path_string() interpolates the masked value, and the query, header, and body proxies read masked values through code that knows nothing about redaction. That is why the endpoint is redacted before any proxy is built from it: a borrowed proxy holds a reference to the endpoint’s field, so a redacted = .. placeholder written against the field’s own type is dropped when the attribute is forwarded onto the proxy, and the proxy keeps only the class. An endpoint that is not a wire model — one whose fields route through proxies with no wire binding of its own — hand-implements ApiRedact instead; the two populations partition every type, so a hand-written impl cannot collide with the blanket.

Credentials and signatures

The seal places credentials and the carrier signs after every member has run, so neither is a field a model could declare. They take the same stance by a different route. An endpoint’s Auth::DEFAULT.redacted(), or a send’s RequestOverrides::redact_credentials(), keeps the shape of the request’s authentication — the headers and query parameters a live placement would write, the headers a signer declares as its outputs() — and writes REDACTED_PLACEHOLDER where each value would go. Nothing is renewed to arrive there and no custody operation runs, so the request never holds a credential at all. Auth::NONE stays NONE: there is nothing to redact.

The twin

Put the two together and a request can be recorded without ever having held a secret. The test framework’s api.query_redacted(endpoint) clones the endpoint, redacts one clone through ApiRedact, finalizes it under redact_credentials so the seal places placeholders, and stashes that twin on the live request’s head. The live request goes to the wire; the twin is what the fixture holds and what replay compares. Nothing in it ever held a live value, so nothing is decoded or searched to mask it. The endpoint must be Clone and ApiRedact, which a WireModel endpoint is. Fixtures and Sensitive Data places the twin among the recorder’s other tiers.

Next: Format Extensions — the per-format attribute vocabularies the contract crates add.

Format Extensions

Advanced. You need this chapter to write a new format, and to understand why XML and protobuf annotations look different from ordinary #[wire(...)] ones. Using a shipped format needs only the short worked examples at the end.

Every attribute so far has been format-neutral: rename and flatten and default mean something in any format. But real formats need vocabulary the core cannot anticipate. XML has to distinguish an element from an attribute from text. Protobuf addresses fields by number and cares about proto2-vs-proto3 presence rules. A URL form has no single convention for repeated keys.

Serde’s answer to this is that there isn’t one: #[serde(...)] is a fixed set, so format-specific needs get solved by parallel derive ecosystems. The wire model’s answer is an extension bag in the binding that any format can write into and read back.

The mechanism: the ext bag

Every Binding, FieldBinding, and VariantBinding carries an ext slice of (&'static str, ExtVal) pairs:

pub enum ExtVal {
    Str(&'static str),
    Int(i64),
    Bool(bool),
}

Three value kinds, all 'static, all Copy — enough to express a field number, an element name, or a boolean mode, and nothing more. The keys are plain strings, and namespaced keys like "xml.root" keep formats from colliding.

You can write into the bag directly:

#[wire(ext("xml.root" = "Order", "proto.id" = 3))]

but you normally shouldn’t. The raw keys are an internal contract between an extension’s macro (which writes them) and its codec (which reads them) — which is why every shipped extension provides a decoration macro to write them and typed readers to read them, so neither library authors nor codec writers ever touch a stringly key.

Anatomy of a format contract

A format contract crate is three things, and the shipped ones are all built the same way:

  1. A marker. One zero-sized type — Xml, Protobuf, UrlForm — so a codec speaking the contract returns Codec<Xml> and an API boundary requiring XML names it. This is the whole of the JSON contract, since JSON is self-describing and needs no annotations at all.
  2. A decoration macro, behind a model feature. #[xml_extension], #[proto_extension], #[urlform_extension] — each accepts format-shaped attributes and lowers them into the core ext bag. Crucially, the macro expands to the core #[derive(WireModel, WireEncode, WireDecode)], so a decorated type is an ordinary wire-model type that happens to carry extra knobs.
  3. Typed readers. Functions like place(), root_name(), xmlns(), field_number(), list_convention() that read the bag back and return a real type instead of an Option<&str> a codec would have to interpret.

The model feature is off by default, and the reason is worth noting: a codec needs only the marker and the readers, never the macro. Gating the macro means a codec crate never compiles a proc-macro it will not use.

The division is the same one the contract/engine split draws everywhere else: the contract crate is tiny and codec-agnostic, and one or more byte engines implement it. capiw_ext_xml defines what XML annotations mean; capiw_quick_xml is one engine that honors them.

The shipped extensions

Four contract crates ship: capiw_ext_json (the Json marker and nothing else — JSON is self-describing), capiw_ext_xml, capiw_ext_urlencoded, and capiw_ext_protobuf. The three with vocabulary each carry a decoration macro behind an off-default model feature; a codec consumes the contract without it.

XML

XML needs to know where a field goes in the document. The decoration macro expands to the three wire derives itself, so a decorated type derives only its ordinary Rust traits:

#[xml_extension(root = "Grantee", xmlns = "http://s3.amazonaws.com/doc/2006-03-01/")]
#[derive(Debug, Clone, PartialEq)]
pub struct Grantee {
    #[xml(attribute)]
    #[wire(rename = "xsi:type")]
    pub kind: String,      // rendered as an attribute, not a child element
    pub id: String,        // a child element by default
}

The field annotations are #[xml(attribute)], #[xml(text)], and #[xml(element)], read back through place() (yielding an XmlPlace). The type-level root and xmlns arguments carry the root element name and namespace, read through root_name() and xmlns(). #[xml_extension] takes a named-field struct; an enum uses the plain derives.

URL forms

A form has no agreed encoding for a list field — APIs variously want repeated keys, bracketed keys, or one delimited value — and the choice is per field, so it rides on the field’s bag:

#[urlform_extension]
pub struct Filter {
    pub tag: Vec<String>,                    // bare, or #[urlform(repeated)]: tag=a&tag=b
    #[urlform(brackets)] pub id: Vec<u32>,   // id[]=1&id[]=2
    #[urlform(comma)] pub kind: Vec<Kind>,   // kind=a,b
}

#[urlform(delim = "…")] covers other separators, and the codec reads the choice back through list_convention(). The UrlForm marker also implements QueryFormat — the format emits key=value text — which is what makes Codec<UrlForm>::encode_qs exist for an endpoint’s query string. Note that the delimited forms lower to the codec-agnostic #[wire(joined = ...)] scalar rule rather than to an XML-style ext key — an extension should reach for a core rule when one already says what it means.

Protobuf

Protobuf is the case that justifies the whole design, because it is non-self-describing: fields are addressed by number, and the decoder needs the mapping before it sees a value.

#[proto_extension]
pub struct Order {
    #[proto(id = 1)] pub id: u64,
    #[proto(id = 2, sint)] pub delta: i32,
    #[proto(id = 3, packed)] pub tags: Vec<u32>,
    pub note: String,                        // no id: numbered sequentially, here 4
}

Fields without an id are numbered sequentially from the last explicit number, starting at 1 — most messages carry no #[proto(id)] at all and let declaration order number them. The other field knobs are sint / fixed (the integer wire encoding; the fixed width resolves from the declared type), packed / unpacked (overriding the syntax-level default for a repeated scalar), and, under proto2, default = <literal>. On an enum, #[proto_extension] numbers the variants: a unit enum’s values from 0 (proto3 requires a variant numbered 0), a data-carrying enum’s oneof members from 1; #[proto_extension(enum_type = "open" | "closed")] overrides the openness. The macro validates every number at compile time — positive, unique per message, within 1..=536_870_911, outside the reserved 19000..=19999 — and rejects an id on a skip or flatten field, which protobuf cannot address. A constant entry is refused the same way: a field-numbered format has no slot for an entry with no number.

The readers are field_number(), variant_number(), syntax(), int_encoding(), packed(), and enum_openness(); the type-level #[proto_extension(syntax = "proto2")] bundle switches the presence semantics wholesale — explicit scalar presence, unpacked repeated fields, closed enums, custom defaults — and the per-field keys override it. A Duration field on a gRPC-transcoded API takes the duration(format = "protobuf") rule from the datetime chapter. The capiw_prost codec wraps prost’s fuzzed varint/tag/length-delimited primitives — deliberately not the derive-based prost::Message trait, because a Capi codec consumes the erased event stream, which a derive-generated Message impl cannot speak.

This is the concrete payoff of a static binding: the codec learns every field number from T::BINDING without ever holding a value.

Writing your own

To add a format, you write a contract crate with the three parts above:

  1. Define the marker and impl Format for YourMarker {}. It’s an unsealed, empty trait, so this is one line in your own crate — no central registry, no orphan-rule problem. Check first that a contract for your format doesn’t already exist: a second marker for an existing format splits it into two mutually incompatible formats as far as the compiler is concerned, which is the one thing worth coordinating.
  2. Decide your keys, namespaced ("myfmt.thing"), and write typed readers over them.
  3. If your format needs author-facing annotations, add a decoration macro behind a model feature that lowers them into #[wire(ext(...))] and expands to the core derives. If it doesn’t — if your format is self-describing — skip this entirely and ship just the marker, as JSON does.

Then a byte engine implements ByteEncode and ByteDecodeWireFormat is the blanket over the two — and returns Codec<YourMarker> from a codec() function. ByteEncode::encode_into (append to a caller-owned buffer) has a provided default over encode_u8, and a scalar rule’s render_bytes / parse_bytes are what an engine calls for a leaf with a rule. Nothing in the core changes, and nothing in the core needed to know your format existed.

Next: Hand-Written Impls and the Value Tree — the last resort when a type’s shape defeats every attribute.

Hand-Written Impls and the Value Tree

The attributes are the front door, not the only door. This chapter collects the ways out: a leaf type that owns its own wire form, a fully hand-written impl, a runtime value tree for shapes you can’t type, and the two knobs for generic and renamed-crate situations.

What a field type owes the derive

Before the two hatches, the contract they satisfy. A type used as a field of a derived model needs four things, and the derive asks for each by name:

  • encode and decodeWireEncode and WireDecode<'de>, or the one-trait WireScalar form for a leaf;
  • a shapeWireShape, the recursive Shape the field binding stores for the type, which the model-driven walker and fixture tooling read;
  • a placeholderRedact, which the WireModel derive requires of every non-skip field type (Value Classes and Redaction).

Derived models get all four from their derives. A hand-written type supplies them by hand — two identity methods for Redact when the type holds nothing sensitive, and a Shape that is at least as permissive as the type’s own WireDecode.

WireScalar: a leaf that owns its form

When a leaf value has a representation no scalar rule expresses, implement WireScalar and derive the encode/decode with the scalar container attribute. A scalar maps to exactly one of the model’s leaf lanes per emission — bool, the integer and float lanes, str, bytes — plus a semantic ScalarKind. Lane is syntax and KIND is semantics: a text format renders whichever lane the scalar writes, while a binary format keys off KIND to choose a native encoding. capi_wire_decimal’s Decimal is the shipped model of the shape:

#[derive(Debug, Clone, Copy, Default, WireEncode, WireDecode)]
#[wire(scalar)]
pub struct Decimal { coeff: i128, exp: i16 }

impl WireScalar for Decimal {
    const KIND: ScalarKind = ScalarKind::Decimal;

    fn write_wire(&self, out: &mut dyn ScalarSink) -> Result<(), CodecError> {
        out.put_number(&self.to_plain_string())      // one lane: the number lane
    }

    fn read_wire(source: ScalarSrc<'_>) -> Result<Self, CodecError> {
        /* parse the number lane, or the text lane a quoting API sends */
    }
}

capi_wire_model::impl_wire_shape_scalar!(Decimal);   // Shape::Scalar(ScalarKind::Decimal)

impl Redact for Decimal {
    fn redacted(self) -> Self { self }
    fn redacted_placeholder(self) -> Self { Self::new(0, 0) }
}

ScalarSink is the sink a scalar writes into — streamed put_str fragments, or one one-shot lane such as put_number, put_i64, put_bool, put_bytes — and ScalarSrc is the leaf it reads back from. The two halves must agree on one lane, or the type cannot survive a codec that keeps that lane native: writing put_i64 while reading only ScalarSrc::Str round-trips through a text format and fails against a value tree or a binary codec. ScalarKind names the semantics — the lane kinds Bool, I64, U64, F64, Str, Bytes, then DateTime, Date, Time, Duration, Decimal, Uuid, and Custom(&'static str) — for the formats that need it.

The derives then emit only the encode_scalar/decode_scalar delegations to the codec’s Encoder/Decoder, bounded on Self: WireScalar. A scalar type carries no bindingWireModel rejects the attribute — and no other wire attribute is allowed anywhere on the item, with the single exception of #[wire(crate = "...")], which steers path resolution rather than wire shape.

Reach for this when the type is a leaf that appears in many places. For a one-off field, a via proxy is less machinery.

Fully hand-written impls

When no combination of attributes expresses the shape, implement the traits directly. grok’s MaxResponseOutputTokens is a wire value that is a union — an integer bound, null for unbounded, or an "inf" string on server-sent session events — which no single scalar lane can name:

pub struct MaxResponseOutputTokens(Option<i32>);

impl WireEncode for MaxResponseOutputTokens {
    fn encode<E: Encoder>(&self, encoder: E) -> Result<E::Ok, E::Error> {
        self.0.encode(encoder)                 // an integer bound, or null when unbounded
    }
}

impl<'de> WireDecode<'de> for MaxResponseOutputTokens {
    fn decode<D: Decoder<'de>>(decoder: D) -> Result<Self, D::Error> {
        /* decode_any, then accept an integer, null, or the "inf" text form */
    }
}

// `Any` keeps the walker exactly as permissive as this type's own decode, so
// fixture tooling accepts every payload the typed path does.
impl WireShape for MaxResponseOutputTokens {
    const SHAPE: Shape = Shape::Any;
}

// The bound masks to zero; the unbounded state stays unbounded, since `None` is
// what omits the field, and inventing a `Some` would add a limit the request never sent.
impl Redact for MaxResponseOutputTokens {
    fn redacted(self) -> Self { self }
    fn redacted_placeholder(self) -> Self { Self(Redact::redacted_placeholder(self.0)) }
}

This is the same hatch JWT’s claims use for their runtime-keyed map. You are writing against the same Encoder/Decoder traits every codec implements, so a hand-written type works with every format — and the WireShape you declare is the promise the walker holds you to: state a shape at least as permissive as what decode accepts, and Shape::Any when the wire form is a union.

The value tree

Sometimes a field’s shape is intentionally open — a spec field, a passthrough blob, a vendor extension. capi_wire_value::Value is the self-describing runtime tree for exactly that: you decode into it when you don’t have, or don’t want, a static type, and the codec reports whatever is on the wire.

pub struct Response {
    pub id: String,
    pub raw: Value,          // whatever the service sent
}

Value implements WireEncode/WireDecode/DynEncode — and WireShape as Shape::Any and Redact, so it can be a field of a derived model — and round-trips through any codec. Numbers are split by sign and width (I64 / U64 / I128 / U128 / F64) to mirror the wire rather than collapsing to one numeric type, with a Number(String) lane for the exact decimal text a fraction or a beyond-128-bit integer arrives as; Bytes holds a byte string; objects keep their entries in insertion order. SchemaDocument is a transparent newtype over it for spec fields whose shape is deliberately open.

Two other things ride on the same tree: the enum capture policy decodes into it, and the test framework’s value-first fixture comparison walks it. There is also an in-memory codecto_value and from_value move between typed wire values and the tree with no byte format in the middle, applying the same binding metadata (names, scalar rules, wraps, flatten, tagging) a self-describing byte codec would.

One codec-specific note: a tree entry is a plain (key, value) pair, which cannot say where XML put something — so the XML codec’s self-describing form spells placement in the key. @name is an attribute of the enclosing element, #text is its text (mixed content concatenates to one entry), and any other key is a child element. XML’s own grammar makes the markers collision-free — neither @ nor # can start an XML name — and the codec reads them back into placement on encode, so a captured element round-trips, and a Value::Object (or a typed map field) written under XML describes its element’s content the same way.

Generics

Type and const parameters work on structs and enums. The generated impls bound exactly the parameters a non-skip field or variant payload uses — T: WireEncode / T: WireDecode<'de> — so a phantom parameter stays unbounded, mirroring serde’s inference. Override it when inference isn’t what you want:

#[wire(bound = "T: MyTrait")]
#[wire(bound(encode = "…", decode = "…"))]   // or set each direction separately

Predicates are written without the where keyword. Three constraints are worth knowing: WireDecode cannot be derived for a type with lifetime parameters (decoding yields owned data), though WireModel and WireEncode accept them; and two enum shapes must stay non-generic — external struct variants and #[wire(string_enum)].

Crate path

Generated code resolves wire-model items through ::capi_wire_model. When the crate isn’t a direct dependency under that name — renamed in Cargo.toml, or reached through a facade — redirect it:

#[derive(WireModel, WireEncode, WireDecode)]
#[wire(crate = "my_facade::wire_model")]
struct Order { id: u64 }

The value must parse as a Rust path and is emitted verbatim, deliberately without a leading ::, so an alias in scope at the derive site (use capi_wire_model as wire; with crate = "wire") works too. All three derives read the one attribute written on the type. There is a matching #[wire(datetime_crate = "...")] for the datetime adapters, defaulting to ::capi_wire_datetime.

This is also why a client crate lists capi_wire_model among its own dependencies, under that name, rather than reaching it through another crate — see the manifest chapter.


That closes the wire model. With types that know how to cross the wire, the next part builds the endpoints that carry them.

The Endpoint Contract

Here’s the payoff of everything so far: with a config, auth, errors, decoders, and types in place, an endpoint is a struct + two macros + one impl Endpoint + one line of decoder wiring. Every endpoint in the rest of the book is a variation on this one shape, so learn it once here.

The archetype

This is the skeleton every JSON endpoint follows — the shape of grok’s Chat Completions endpoint, with the path template and the derive line in the form the reference clients use:

#[capi]                                    // constructor + setters
#[derive(Debug, Clone, WireModel, ApiEndpoint)]   // wire attributes + field-location routing
#[endpoint(path_template = "/v1/chat/completions")]
pub struct PostChatCompletions {
    #[endpoint(location = "body")] pub model: String,
    #[endpoint(location = "body")] pub messages: Vec<Message>,
    // ...
}

impl Endpoint for PostChatCompletions {
    type ApiConfig = GrokConfig;
    type Output = ChatResponse;
    type Error = GrokError;
    type Decoder = BodyDecoder;
    type Requires = Http;
    fn method(&self) -> Method { Method::POST }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path(self.path_string())
    }
    fn content_type(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue> { 
        "application/json"
    }
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        config.json_lib().encode_body(&self.body_fields())
    }
}

impl DecodeBodyFn for PostChatCompletions {
    fn decode_fn() -> BodyFnDecoder<Self> { GrokResponse::decode }
}

Five lines of associated types, two required methods, one body method, one decoder line. That’s the whole contract.

The Endpoint trait

pub trait Endpoint {
    type ApiConfig: ApiConfig;                   // which config this endpoint binds to
    type Output: Unpin + MaybeSend + 'static;    // the decoded success type
    type Error: StdError;                        // your modeled domain error (Modeling Your Domain Error)
    type Decoder: ResponseDecoder<Self>;         // the decoder marker (The Decoder Contract and the Status Ladder)
    type Requires: Capability;                   // Http, WebSocket, Grpc

    fn method(&self) -> Method;                         // required
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl;  // required
    // provided (override as needed):
    fn headers(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<HeaderMap, HttpHeaderError>;
    fn auth(&self) -> Auth;
    fn accept(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue>;
    fn query_string(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToQueryString, ParamsError>;
    fn content_type(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue>;
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError>;
    fn rate_limit(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToRateLimiter;
    fn signer(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToSigner;
}

Only method and url are required; the rest have defaults. Implement members in declaration order (methodurlheadersauthacceptquery_stringcontent_typebodyrate_limitsigner), omitting the ones you don’t override. The Decoder: ResponseDecoder<Self> bound is what turns a mismatch between the marker’s output and Output into an error at the declaration site; an endpoint that decodes its own responses declares type Decoder = Self and implements ResponseDecoder itself (the decoder system in depth).

The (config, context) pair

Every construction method takes both. The config is what is true of every request this client makes: the base URL, the codecs, the default limiter. The context is scoped to the one request being built, and it is where the per-request facts live:

ReadFor
context.id()echoing the framework’s own context identity into a header
context.rng()a multipart boundary, an idempotency key — this request’s own forked stream, so both replay
context.clock()a timestamp in a payload; pair with replay_system_time(name) to make it reproduce
context.get_extension::<T>()anything the caller attached for this request

It is the same pair the decoder’s prepare_request and decode and the seal receive — a pipeline member gets the context alone — so everything that observes a request sees it the same way.

ApiConfig::base_context() is a different thing and reading it here is almost always a mistake: it is the template request contexts are derived from, with no context ID and a stream every request on the client shares. Configure it (clock, RNG, notifications) and every request inherits the setup; read this request’s state from the context your method was handed.

Three gotchas worth a callout

  • It’s query_string(), not query(). query() is the call funnel on the interface; the endpoint method that builds the query string is query_string.
  • body(self, …) consumes self, and runs last. It takes the endpoint by value (the others take &self), so it’s the final method the framework calls.
  • url() must be absolute and exclude the query string. Return the full URL; the framework appends the query string built by query_string() separately.

query() is the one funnel

There is no get() / post() / download(). The endpoint’s type carries the method, body, output, and decoder, and one call runs it:

let response = api.query(PostChatCompletions::new(model).messages(msgs)).await?;

This is why switching an output is just handing query() a different type — there’s no parallel method surface to keep in sync. The same call has a second spelling, endpoint.query(&api), and a few relatives that still run the one path: api.scoped() for per-request overrides, api.head(&endpoint) to probe an endpoint’s headers without its body, and finalize_request to build the request without sending it (endpoint shapes).

The two derives

An endpoint carries two derives, and each owns one attribute namespace. #[derive(WireModel)] owns #[wire(...)]: it makes the wire attributes on the endpoint’s fields legal, gives every field the omit-on-None presence rule and the rest of the wire vocabulary, and emits the Redact impl that lifts onto Capi’s ApiRedact — so endpoint.clone().redacted() masks every plane at once, path fields included, and a fixture can hold a request that never held a secret (Value Classes and Redaction). #[derive(ApiEndpoint)] owns #[endpoint(...)]: it reads each field’s location and generates the proxies, forwarding any #[wire(...)] it finds onto them without parsing it. An endpoint with no wire-attributed fields compiles with ApiEndpoint alone; the reference clients derive both on every endpoint, and this book does too.

What #[capi] generates

#[capi] is the constructor-and-setters macro, and it is deliberately dumb so it never fights the wire mapping. It emits:

  • new(...), taking every required field as a positional parameter — a field is required unless it is an Option (defaults to None), a bool (false), or a Vec (empty). The constructor allows clippy::too_many_arguments, since required fields map straight to parameters.
  • A setter per field, consuming and returning self. Primitives — the integer and float types, bool, char — are taken as themselves, so a bare literal infers; every other type is impl Into<T>; a Vec<T> takes impl IntoIterator<Item = T> for a primitive T and impl IntoVec<T> (any iterator of items Into<T>) otherwise. The primitive match is syntactic, so an alias such as type Grams = u32; reads as non-primitive — mark the field #[capi(no_into)].
  • A getter per private field, named after the field.
  • A rustdoc # Parameters section on the struct, one line per field, carrying the wire contract the field’s attributes declare.

Container-level #[capi(...)] beside the macro sets defaults for every setter — no_into, no_strip_option (Option<T> setters take Option<T>), borrow_self, bool (parameterless true setters), prefix = "set_", the generate* switches, and generate_delegates(..) for a parallel setter block on a wrapper type — and field-level #[capi(skip | generate | rename = ".." | no_into | doc = "..")] overrides them for one field. #[capi(no_setters)] and no_new drop either half; #[capi(interface)] is the other mode entirely, the one the wiring chapter uses on the API type. Enums are not supported: model a string-valued enum with #[wire(string_enum)].

Field-location routing

#[derive(ApiEndpoint)] reads a #[endpoint(location = …)] on each field and generates proxy accessors your impl Endpoint consumes. It registers only the endpoint attribute; the #[wire(...)] attributes a field may carry belong to WireModel, which is why the two are always derived together — the endpoint derive forwards #[wire] onto the proxies it generates and never parses it. The locations:

locationGoes toConsumed by
"path"the URL path (via path_template placeholders)url() via self.path_string()
"query"the query stringquery_string() via self.query_fields()
"body"the request bodybody() via self.body_fields()
"header" (or location(header))a request headerheaders() via self.header_fields()
"skip"nowhere (local-only field)

So a mixed endpoint wires each proxy to the matching codec:

fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
    config.base_url().path(self.path_string())               // path fields
}
fn query_string(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToQueryString, ParamsError> {
    config.form_lib().encode_qs(&self.query_fields())        // query fields → urlencoded
}
fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
    config.json_lib().encode_body(&self.body_fields())       // body fields → JSON
}

path_template = "/v1/places/{places_id}" names the placeholder a location = "path" field fills; a placeholder-free template yields a &'static str from path_string(). Header fields are consumed through the header plane’s encoder, the one line the table above elides:

fn headers(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<HeaderMap, HttpHeaderError> {
    config.headers().try_merge_with(
        capi_rs::http_headers::WireHeaders.encode(&self.header_fields())?,
    )
}

Per-field #[wire(rename = …, joined = ",", …)] controls the wire names, exactly as in the type toolkit. (Both location = "header" and location(header) parse; the paren form is common in the reference crates.) Three container options round out #[endpoint(...)]: default_location = "path" | "query" | "body" for fields that name none, body_envelope = "key" to nest the body proxy under one wire key (a field opts out with #[endpoint(body_envelope = false)]), and location = "none" (or "skip") for a field that belongs to no plane.

Module organization

The convention is one file per operation, under endpoints/<resource>/<verb>.rsendpoints/chat/completions/post.rs, endpoints/places/places/get.rs. Each file holds one endpoint struct and its impls, so the module tree mirrors the API’s own resource/verb shape and an endpoint is trivial to find.

Checklist

An endpoint is done when it has:

  • a struct with #[capi] + #[derive(WireModel, ApiEndpoint)] + #[endpoint(path_template = …)];
  • #[endpoint(location = …)] on every field that goes on the wire;
  • impl Endpoint with the five associated types and method + url, overriding only what varies;
  • one line of decoder wiring (impl DecodeBodyFn naming your shared decoder).

The next two chapters vary this: request bodies in every shape, then a cookbook of GET/POST/PATCH/DELETE templates.

Request Bodies: JSON, Forms, Raw Bytes, and Multipart

body(self, config, context) is the one endpoint method that consumes the endpoint, and it returns anything that implements ToBody. That single return type is why the same method covers a JSON object, a raw file, a multipart upload, and a policy-signed form — they are all different ToBody values. This chapter walks the range, then covers the Bytestream primitive underneath all of them.

ToBody and the encode call

body() returns impl ToBody — a sealed trait with one method, to_body(self) -> Result<RequestBody, BodyError>, blanket-implemented for anything that converts into a Bytestream (TryInto<Bytestream>, with the conversion’s error folding into BodyError). RequestBody has two arms: Ready(PreparedBody), which every ordinary body takes, and PolicySigned { signing, body }, for a body whose bytes cannot exist until a policy is signed — the signing chapter covers that one. You rarely construct a PreparedBody by hand; the codec does it. A JSON body is one line:

fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
    config.json_lib().encode_body(&self.body_fields())   // → EncodedBody, which is ToBody
}

A URL-encoded form body is the same line with form_lib(). encode_body returns an EncodedBody carrying the bytes and a BodySpec — the codec and the type’s shape, which fixture tooling reads to decode the body structurally — and it implements ToBody, so there is no manual Bytestream wrapping at the call site.

encode_body vs encode_to_vec. Different methods, different jobs. encode_body(&value) is the request-body encoder: it returns an EncodedBody ready to be a body, spec included. encode_to_vec(&value) returns a bare Vec<u8> and is for the streaming and WebSocket paths that hand raw bytes elsewhere. In an endpoint body(), you want encode_body.

For a genuinely empty body, return EMPTY_BODY (a fresh empty Bytestream). Content type precedes as PreparedBody’s own content type (a multipart body knows its boundary) > the endpoint’s content_type() > none, and Content-Length is set only when the body knows its exact length — an empty body sends neither header, so a GET stays bare.

Raw and binary bodies: FileBlob’s two paths

The highest-consequence body decision is how a binary payload travels, because FileBlob has two paths and they produce completely different wire bytes:

  • Verbatim binary — a FileBlob field with no location attribute, returned straight from body():

    #[capi]
    pub struct PutObject { pub body: FileBlob /* no #[endpoint(location=...)] */ }
    impl Endpoint for PutObject {
        fn body(self, _config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
            Ok(self.body)   // FileBlob: ToBody streams the raw bytes
        }
    }
  • Base64 inside a JSON body — a FileBlob as a location = "body" scalar field encodes as a base64 / data-URL string (it’s a WireScalar), so it rides inside the JSON object like any other field.

Same type, opposite results: a bare ToBody field streams the raw bytes; a body-scalar field base64-encodes them into JSON. Choosing wrong sends base64 text where the server expects octets, or vice versa. This is the one body footgun to internalize. A FileBlob is backed by bytes, a filesystem path, or a browser blob; the file-backed form classifies as replayable, so a retry or redirect re-reads the file rather than losing it.

Multipart

A multipart endpoint sets content_type() to "multipart/form-data" and builds the body from field-level #[endpoint(location = "body", multipart(...))] annotations, in declaration order. location = "body" is required: multipart resolution runs over the body fields, and a field that names no location belongs to no plane. grok’s file upload shows the four roles:

#[endpoint(location = "body", multipart(name = "purpose"))]
pub purpose: Option<String>,                                   // a text part, absent when None
#[endpoint(location = "body", multipart(file_name_for = data))]
pub name: String,                                              // metadata only: the file part's filename
#[endpoint(location = "body", multipart(name = "file", kind = "stream"))]
pub data: FileBlob,                                            // a streamed file part
#[endpoint(location = "body", multipart(mime_type_for = data))]
pub content_type: String,                                      // metadata only: the file part's Content-Type
  • A text part has no kind. name = "…" names it on the wire (the bare field identifier otherwise — a #[wire(rename)] on a multipart field is refused, because that key names the field on the endpoint’s wire binding). The default serializer = "form" renders the value through Display; serializer = "json" and friends encode through config.{json}_lib().encode_to_vec(..), as a typed part when content_type = "…" is given and as a bare stream otherwise.

  • A stream partkind = "stream" — flows through TryInto<Bytestream> into Part::stream, so a large upload never buffers in full. file_name = "…" and mime_type = "…" attach literal metadata.

  • A metadata-only fieldfile_name_for = <field> and/or mime_type_for = <field> — never appears on the wire; its runtime value decorates the named stream part. The “for” direction puts the declaration on the provider, so “this field is not a part” needs no separate flag.

  • A one-of fieldkind = "one_of" — is a field whose type implements MultipartParts and emits its own part(s), owning their wire names:

    impl MultipartParts for SttAudioSource {
        fn push_parts(self, multipart: &mut Multipart) -> Result<(), BodyError> {
            match self {
                Self::Url(url)   => multipart.push_part(Part::text("url", url)),
                Self::File(blob) => multipart.push_part(blob.to_stream_part("file")?),
            }
            Ok(())
        }
    }

An Option part is omitted when None, and a Vec part emits one part per element. The body() for a multipart endpoint is one call to the generated self.multipart_body(config, context), which draws the boundary from the request’s own RNG fork — so concurrent uploads never race for one, and a recorded upload replays byte-for-byte — and sets the content type with it.

Beyond form-data

multipart/form-data is one subtype. capi_base_encoders::Multipart builds any of them by hand, for the endpoints the derive’s field roles do not describe:

let mut body = Multipart::related(context.rng())        // or ::mixed(rng), ::new(rng) for form-data
    .with_root_type("application/json")                  // RFC 2387 envelope parameters
    .with_start("<metadata>");
body.push_part(Part::typed("application/json", metadata_bytes));   // disposition-free, typed
body.push_part(Part::file("file", blob)?);                         // a named form-data part
body.push_part(Part::raw(headers, stream));                        // every header under your control
let prepared = body.prepared()?;                                   // a PreparedBody, boundary and all

Part::text, text_typed, stream, file, typed, raw, and raw_segments are the part constructors; with_file_name / with_mime_type decorate a stream part. A raw part’s content may be several segments emitted back to back, so a section can compose a serialized message head with a live body stream without buffering them together — which is how capi_http_batch writes a captured request into a multipart/mixed envelope. The response side has a parser to match: ResponseBoundary::from_content_type(&content_type)?.split(&body) lifts the boundary from the response’s own Content-Type — never guessed, never carried over from the request — and splits a buffered multipart/* body into its parts, each with its headers and bytes. The Batching chapter is the worked consumer.

Content-coded bodies

A body’s bytes can be transformed on the way out with a StreamCodec: stream.with_codec(Box::new(codec)) wraps a Bytestream in one, and with_codec_sized(codec, ContentLength::Actual(n)) does the same while declaring the coded length, for a codec whose output size is known up front. The capie_* crates ship the codecs — capie_gzip, capie_miniz_oxide (deflate), capie_brotli, and capie_aws_chunked — and S3’s streaming upload is the worked case: with a trailing checksum declared, the blob streams through an AwsChunkedEncoder whose framed length is computed ahead, so the transport emits a real Content-Length and the SigV4 signer, told by the STREAMING-UNSIGNED-PAYLOAD-TRAILER token not to read the body, lets it go out unbuffered.

The Bytestream primitive

Underneath every body (and every response) is Bytestream — Capi’s one body type, portable across sync, async, std, the browser, and no_std. It defines its own read traits rather than using std::io::Read (which needs std), so it works on every target.

Constructing one

let s = Bytestream::from_vec(vec![1, 2, 3]);          // owned bytes; ContentLength::Actual set
let s = Bytestream::from_slice(&bytes);                // copied
let s: Bytestream = vec![1, 2, 3].into();             // via Into
let s = Bytestream::empty();                          // const-constructible
let s = Bytestream::from_reader(reader, Some(ContentLength::Actual(1024)));  // any reader
let s = Bytestream::from_stream(stream, None);        // an async stream of chunks
let s = Bytestream::from_path("upload.bin")?;         // file-backed, re-readable (std)

ContentLength distinguishes Actual(u64) (exact — the transport sets Content-Length) from Estimated(u64) (a hint only — the body streams chunked, the value kept for progress bars). The distinction matters because compression means the wire length and the decoded length often differ, and not every client reports which.

What a body costs to replay

A stream also knows what it is. source_kind() reports a BodySource: Empty, Buffered (owned in memory, or re-readable in place from a seekable source — a replay costs nothing new), or Streaming (produced incrementally by a reader — a replay pins every transmitted byte until the exchange completes, buffering a body of indeterminate size). Request assembly records the same classification on the request head as the body_source spec, and the paths that would have to hold a whole body read it there: capturing a request into a batch envelope refuses a live stream (DrainError::StreamingBody), and a signer that reads the body refuses one rather than materializing it. Whether an upload fits in memory is the developer’s judgement; the framework names the cost and does not guess.

Shared clones for retry

Once bytes are read, they’re gone — a problem for retries. Bytestream solves it with shared clones: Clone (and the explicit create_shared_clone()) yield a handle sharing one internally-buffered source, each with its own read position, so a dozen clones cost no more than one. This is what lets a retry runner, a redirect, and the seal re-send a body without buffering it upfront.

Progress hooks

Bytestream supports pre- and post-read hooks (add_sync_pre_hook / add_sync_post_hook, with add_async_pre_hook / add_async_post_hook on the async lane) keyed by a HookMode (EveryRead, OnFreshData, OnComplete, PerClone(id), …). A post-read hook receives a PostHookContext and is the idiomatic progress bar:

stream.add_sync_post_hook(HookMode::OnFreshData, |ctx| {
    println!("read {} of {} bytes", ctx.position, total);
    Ok(())
});

Hooks return Result<(), BytestreamError>; returning an error cancels the read — the building block for cancellation. This is the same hook surface the framework uses to drive transfer stages, which is how an application reaches read progress and bandwidth caps without attaching hooks itself — the framework can place them where the transmitted body is knowable, and a hand-attached hook cannot.

A gRPC endpoint’s bodies — a Frame for a unary call, a FramedBody over a MessageSource for a client-streaming one — are another ToBody family; the gRPC chapter has them.

With bodies covered, the next chapter is a cookbook of complete endpoint shapes.

Endpoint Shapes: GET, POST, PATCH, DELETE, and Absolute-URL

A cookbook. Each entry is a complete, copy-adaptable template built on the contract — the differences between them are small and worth seeing side by side.

GET with path and query

Only method and url are required; a GET with query parameters adds query_string:

#[capi]
#[derive(Debug, Clone, WireModel, ApiEndpoint)]
#[endpoint(path_template = "/v1/places/{places_id}")]
pub struct GetPlaces {
    #[endpoint(location = "path")]  pub places_id: String,
    #[endpoint(location = "query")] pub language_code: Option<String>,
}
impl Endpoint for GetPlaces {
    type ApiConfig = GooglePlacesConfig;
    type Output = Place;
    type Error = GooglePlacesError;
    type Decoder = BodyDecoder;
    type Requires = Http;
    fn method(&self) -> Method { Method::GET }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path(self.path_string())
    }
    fn query_string(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToQueryString, ParamsError> {
        config.form_lib().encode_qs(&self.query_fields())
    }
}
// + impl DecodeBodyFn { fn decode_fn() -> BodyFnDecoder<Self> { GooglePlacesResponse::decode } }

POST with a body

Add a body() that encodes the body fields — the archetype from the previous chapter. Nothing else changes.

PATCH with a sparse or tri-state body

A PATCH usually wants to touch only some fields. Two tools, from the type toolkit:

  • A plain Option<T> — a None field is omitted on encode and an absent field decodes as None; that is the wire model’s own rule, with no attribute needed.
  • #[wire(tristate)] over Tristate<T> — distinguishes omit (Keep), set (Set), and explicit null (Clear), which a PATCH that can clear a field needs:
#[endpoint(path_template = "/v1/custom-voices/{id}")]
pub struct PatchCustomVoice {
    #[endpoint(location = "path")] pub id: String,
    #[endpoint(location = "body")] #[wire(tristate)] pub name: Tristate<String>,
}
impl Endpoint for PatchCustomVoice {
    // ... method = PATCH ...
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        config.json_lib().encode_body(&self.body_fields())   // same encode path; the wire attr does the work
    }
}

DELETE and empty responses

Two shapes, depending on what the server returns:

  • No contentOutput = (), decoded by a helper that succeeds on 2xx and ladders errors otherwise (grok’s shared empty; S3’s decode_headers_only_with_headers for a headers-only reply). No body() override needed.
  • A confirmation body — some DELETEs return { "deleted": true }. Model Output = bool and unwrap it with DecodeWrappedBodyFn (below) rather than exposing the wrapper type.

Response-unwrap: Output is the inner type

When the wire body is a wrapper but you want your Output to be the inner value, implement DecodeWrappedBodyFn: decode the wrapper, return its inner field, and the wrapper type never appears in your public API.

impl DecodeWrappedBodyFn for AutocompletePlaces {
    type Wrapper = AutocompleteResponse;                 // the on-wire shape
    fn decode_wrapper_fn() -> WrappedBodyFnDecoder<Self, Self::Wrapper> {
        GooglePlacesResponse::decode                     // decode the wrapper; Output = its inner
    }
}

TryDecodeWrappedBodyFn is the fallible-unwrap variant when the inner extraction can fail.

Header-only responses

Some operations answer entirely in headers (S3’s HeadObject). Build the Output from Default and populate it from the parsed response headers. OutputHeaders and decode_headers_only_with_headers here are aws_s3’s own — a per-crate shape, not a framework trait — and the pattern is what to copy:

impl OutputHeaders for HeadObjectOutput {
    type Headers = HeadObjectHeaders;
    fn set_headers(&mut self, headers: HeadObjectHeaders) { /* copy fields in */ }
}
impl DecodeBodyFn for HeadObject {
    fn decode_fn() -> BodyFnDecoder<Self> {
        decode_headers_only_with_headers::<HeadObjectOutput, HeadObjectError>
    }
}

HEAD against any endpoint

Every shape so far is something you define. This one is something you do to an endpoint you already have.

Note the contrast with the section above: S3’s HeadObject is an endpoint, because S3 publishes it as an operation with its own headers and output type. What follows is different — a way to issue a HEAD against any endpoint, including ones whose service never advertised such a thing.

HeadRequest is an extension trait in capi_extras, and it arrives with the standard prelude:

use capi_rs::prelude::*;      // brings HeadRequest into scope

let head = api.head(&endpoint).await?;       // ResponseHead: status, version, headers
if head.status.is_success() {
    let value = api.query(endpoint).await?;  // the same endpoint, still yours to send
}

Notice what you did not write: no HeadGetPlaces type, no method() override, no second decoder, no change to the endpoint at all.

Under the hood head clones the endpoint into HeadOf<Endpt> — an ordinary adapter endpoint, in the prelude and constructible directly — whose method is HEAD and whose url, headers, auth, accept, query_string, rate_limit, and signer forward to the inner endpoint’s; body construction and Content-Type are skipped, both meaningless without a body. Its decoder, HeadDecoder, forwards the inner decoder’s prepare_request hook so the head carries the same specs, and resolves to the ResponseHead — status, version, and headers — with the body dropped unread, per HEAD semantics.

Two practical details:

  • The endpoint is borrowed, and cloned inside. head takes &Endpt and requires Endpt: Clone; the clone rides in HeadOf, so the original stays intact for the follow-up query. A non-Clone endpoint cannot be probed.
  • It runs through the chain’s runner. head accepts anything that opens a scoped request, so per-request overrides ride along and api.retry(spec).head(&endpoint) retries the probe.

The bounds are the same capability check query applies: the endpoint must be HTTP (Requires = Http) and the client must Supports it.

Caller-supplied absolute URL

For an endpoint that hits a URL handed to it — a pre-signed link, a next-page/download URL from a prior response — store the whole URL and return it verbatim from url(), and opt out of auth so a foreign host never sees your credential:

pub struct DownloadContent { pub url: String }
impl Endpoint for DownloadContent {
    fn url(&self, _config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        self.url.clone()                 // returned as-is, not base_url().path(...)
    }
    fn auth(&self) -> Auth {
        Auth::NONE                   // don't send the credential off-host
    }
}

That Auth::NONE is the auth model opt-out in practice, and the absolute-URL shape is the foundation for binary downloads and cursor pagination that follow a server-provided link. capi_url accepts any scheme with an authority, so the URL is passed through as the service issued it.

Header parameters

A required header with a typed value is a location(header) field, and its consumption is one line in headers(). Google Places’ field mask is the shape — a list joined into one header value, type-checked against the response it selects:

#[capi]
#[derive(Debug, Clone, WireModel, ApiEndpoint)]
#[endpoint(path_template = "/v1/places/{places_id}")]
pub struct GetPlaces {
    #[endpoint(location = "path")]  pub places_id: String,
    #[endpoint(location = "query")] #[wire(rename = "languageCode", default)]
    pub language_code: Option<String>,
    #[endpoint(location(header))]   #[wire(rename = "x-goog-fieldmask", joined = ",")]
    pub field_mask: Vec<FieldMask<Place>>,
}

impl Endpoint for GetPlaces {
    // ...
    fn headers(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<HeaderMap, HttpHeaderError> {
        config.headers().try_merge_with(
            capi_rs::http_headers::WireHeaders.encode(&self.header_fields())?,
        )
    }
}

A signed PUT with a streamed body

An upload against a signed service combines three shapes at once: a raw body, a declared signer, and a content type the endpoint owns. S3’s PutObject:

impl Endpoint for PutObject {
    type ApiConfig = AmazonS3Config;
    type Output = PutObjectOutput;
    type Error = PutObjectError;
    type Decoder = BodyDecoder;
    type Requires = Http;

    fn method(&self) -> Method { Method::PUT }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl { /* bucket + key */ }
    fn auth(&self) -> Auth { Auth::DEFAULT.optional() }   // the key material the signer maps
    fn content_type(&self, _config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue> {
        self.content_type.clone()                            // the stored object's own type
    }
    fn body(self, _config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        let trailer = self.checksum.as_ref().and_then(ObjectChecksumSpec::trailer);
        streaming_body(self.body, trailer)                   // raw bytes; aws-chunked when a trailing checksum is declared
    }
    fn signer(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToSigner {
        config.service_signing()
    }
}

Auth::DEFAULT.optional() is what hands the SigV4 signer its key from the store — and lets an anonymous request through where a bucket policy allows one — while signer declares that the request is signed. The signing chapter has both halves.

Other things to do with an endpoint

Beside query and head, an endpoint value has two more verbs. finalize_request (capi_transport::Finalize, imported by name rather than from the prelude) completes the request’s construction — the pipeline’s request phases, credential placement, signing — and hands back the exact wire-form request without sending it, for a carrier that will put the request inside another message. And an capi_http_batch batch or changeset is itself an honest endpoint: arm it with the endpoints it carries and api.query(batch) sends the envelope and decodes each part with its own endpoint’s decoder. Both are the capture plane; the Batching chapter is the worked consumer.

Those are the everyday shapes. Advanced Response Patterns moves to the responses that need more than a single decoded value — streaming first.

The Decoder System in Depth

The decoder contract covers what a client author needs day to day: name a Decoder marker, write one decode_body, and let the status ladder do the rest. This chapter is the contract underneath — the trait the pipeline actually drives, the hooks it exposes before and after the wire, the door a decoder gets for follow-up sends, and why the whole layer is built from marker types and strategies. It is the chapter to read before building a decoder the shipped ones don’t cover.

ResponseDecoder

Every endpoint names a type implementing ResponseDecoder<Self> through its Decoder associated type, and the request pipeline drives decoding through that type alone:

pub trait ResponseDecoder<Endpt = Self>
where
    Endpt: Endpoint,
{
    /// Typed state captured from the endpoint before request construction consumes it.
    type State: MaybeSend + 'static;

    fn state(endpoint: &Endpt) -> Self::State;

    /// Request facts this decoder needs recorded before dispatch. Head-only; the default records nothing.
    fn prepare_request(
        endpoint: &Endpt,
        config: &Endpt::ApiConfig,
        context: &ApiContext,
        head: &mut RequestHead,
    ) {}

    fn decode<Access>(
        rsp: Response,
        extension: ExtensionOf<Endpt::Requires>,
        config: Endpt::ApiConfig,
        context: ApiContext,
        client: Access,
        state: Self::State,
    ) -> Result<Endpt::Output, ResponseError<Endpt::Error>>
    where
        Endpt::Requires: Capability,
        Access: FollowUpAccess<Endpt::Requires>;
}

On the async lane decode returns impl Future<Output = …> + MaybeSend; the rest is identical. The pipeline calls the three members in order, and each has a job the others cannot do:

  1. state(&endpoint) runs before request assembly, while the endpoint is still available. Endpoint::body(self) consumes the endpoint, so anything the decoder will need at decode time has to be snapshotted here: () for a decoder that needs nothing, the endpoint itself for a paged decoder that re-derives the next page’s request from it, an endpoint clone for a switching decoder that hands the response to a sub-endpoint.
  2. prepare_request(endpoint, config, context, &mut head) runs during assembly, after the required capability’s own prepare_request and before any member, the seal, or a recorder sees the head. It receives the same (config, context) pair the endpoint’s own methods do, and it is head-only by design: bodies and URLs belong to the Endpoint methods that own them. What a decoder records here is what the decode contract implies for the request — the response specs below, or a header the exchange needs.
  3. decode(rsp, extension, config, context, client, state) receives the raw response — head and body stream — plus the capability’s typed extension to the plain HTTP exchange (a WebSocket’s upgraded connection; () for a response-shaped capability), the request’s owned config and context, the door, and the state from step 1. It returns Endpt::Output, so the endpoint declaration is the single source of truth for the output type; a marker’s blanket impl asserts that what it produces is what the endpoint declared, and a mismatch fails at the endpoint declaration site.

Declaring the response before it arrives

A response head has no channel of its own for “here is what I decode into”, and by the time a response arrives the endpoint that knows has been consumed. So the declaration rides out with the request, as ResponseSpecs on the request head’s specs, written by prepare_request:

pub struct ResponseSpecs {
    pub ok: Option<BodySpec>,      // how a 2xx payload is read
    pub error: Option<BodySpec>,   // how a 4xx/5xx envelope is read
}

ResponseSpecs::wire::<Shipment, ParcelErrorWire>(config.json_lib())   // the common JSON shape
ResponseSpecs::ok_only::<Shipment>(codec)     // failure bodies are not a model of yours
ResponseSpecs::error_only::<ParcelErrorWire>(codec)   // the success payload is a stream

The two payloads are separate because they are different models: a 2xx decodes into the output, a 4xx/5xx into the API’s error envelope — and error bodies routinely echo request parameters back, secrets included. A 1xx or 3xx has no declared payload; those bodies are not the endpoint’s response. Fixture tooling reads the specs to decode a recorded response structurally: masking its sensitive fields before it reaches disk and comparing it under the model’s classes at replay. A decoder that declares nothing falls back to comparing the bytes through the content-type registry. Every body-decode trait carries a defaulted response_specs(config) hook for exactly this, and BodyDecoder’s prepare_request writes its answer onto the head.

The door

A decoder that needs another exchange to finish its job — the next page, a form submission, a resumed stream — does not receive a client. It receives a door: Access: FollowUpAccess<Endpt::Requires>, captured on the endpoint’s required lane. Opening a scope on it — client.follow_up(config, &context) — runs the same traversal every other exchange runs, so a follow-up carries the pipeline, the seal, the budget, and the transmit invariants without the decoder naming any of them. The trait carries a hidden marker supertrait; the set of doors is the framework’s, and an exchange composed anywhere else is not expressible.

An output that outlives the decode call — a page iterator, a resumable stream — is handed a door of its own: client.detach() yields a FollowUpChannel, owned, 'static, and erased, so nothing about the value it was built from has to outlive the call. The channel is a send channel, not a client: it claims no budget markers, and a budget value that reaches an adapter through it is refused loudly rather than dropped. The capability plane chapter has the door’s scopes.

Why Decoder is a marker

type Decoder = BodyDecoder names a zero-sized marker, not the decode logic. If decoders were selected by blanket impls — impl ResponseDecoder<E> for E where E: DecodeBody, … where E: DecodeBytestream, and so on — the impls would overlap for an endpoint implementing two, and the compiler would reject the set. A marker per lane makes the choice explicit on the endpoint and keeps one decoder serving every endpoint that shares a response shape.

The marker is generic over a strategy: BodyDecoder<S = ViaEndpoint>. The default strategy forwards to the endpoint’s own companion impl — DecodeBody for a buffered body, DecodeDocuments for a document stream through DocumentStreamDecoder<Framing, S = ViaEndpoint> — so the everyday spelling is type Decoder = BodyDecoder; plus one impl DecodeBody. A strategy of your own carries the decode behaviour for a whole family of endpoints: name it once, and the endpoints need no companion impl at all. That is why the layer is marker plus strategy rather than blanket impls — Rust’s orphan rule forbids impl Trait for all T, and a concrete strategy type carries the behaviour without the overlap:

pub struct PaychexSingleResponse;

impl<E> DecodeBodyStrategy<E> for PaychexSingleResponse
where
    E: Endpoint<ApiConfig = PaychexConfig, Error = PaychexError>,
    E::Output: for<'de> WireDecode<'de> + WireShape,
{
    fn decode_body(body: Vec<u8>, header: ResponseHead, config: &E::ApiConfig, context: ApiContext)
        -> Result<E::Output, ResponseError<E::Error>>
    {
        decode_single::<E::Output>(body, header, config, context)
    }
}

pub type PaychexSingle = BodyDecoder<PaychexSingleResponse>;   // type Decoder = PaychexSingle;

The newest clients are built this way: one strategy per response convention, an alias per strategy, and endpoints that name the alias.

The escape hatch: type Decoder = Self

When no marker fits, the endpoint implements ResponseDecoder on itself — the Endpt parameter defaults to Self — and declares type Decoder = Self;. No marker, no strategy, full control of all three hooks: the endpoint’s own state snapshots whatever it likes, its prepare_request stamps the head, and its decode reads the raw response with the door in hand. The shipped decoders are all built on this contract, so nothing they do is closed to an endpoint that takes it directly.

The lanes

Each shipped decoder is one implementation of the contract above, and each has its chapter:

LaneMarkerCompanion traitChapter
Buffered bodyBodyDecoder<S>DecodeBody, DecodeBodyFn, DecodeWrappedBodyFn, TryDecodeWrappedBodyFnthe decoder contract
Raw bytesBytestreamDecoderDecodeBytestreamstreaming
Resumable downloadResumeDownloadDecoderResumableDownloadstreaming
SSE, chunked, documentsSseDecoder, ChunkedDecoder, DocumentStreamDecoder<Framing, S>DecodeSse, DecodeChunked, DecodeDocumentsstreaming
PagesPagedDecoderDecodePaged, DecodePagedSimple, PaginatedResponsepagination
SwitchingSwitchDecoderSwitchDecodeswitching
Resumable and flat-map streamsStreamFnDecoder, FlatMapDecoderDecodeResumable + Resumable, FlatMapDecodecomposing streams
Batch envelopesthe capi_http_batch envelope decodersbatching
WebSocketWebSocketDecoderDecodeWebSocketWebSocket
gRPCGrpcDecoder, GrpcStreamDecodergRPC
HTML formsFormFlowDecoderFormFlowHTML forms

Every lane that is not the buffered body runs its own framing on the success path and takes a decode_error hook (or, for chunked bodies, none at all) for the failure path — the status ladder is a body decoder’s, and the streaming chapter says what each lane does instead. For the machinery underneath all of them, see Under the Hood.

Streaming: SSE, Documents, Chunked, Bytestream, and Resumable Downloads

A streaming endpoint isn’t special machinery — it’s an ordinary endpoint whose Output is a stream. Set three things and you have one: a StreamIterator Output, a Decoder for the framing, and a companion trait that decodes one frame.

The foundation: one StreamIterator

Every stream handle in Capi is a StreamIterator<T, Meta = ()>. Its item type is hardwired to Result<T, QueryError> (the second generic is per-stream metadata, not the error), and it exposes inherent next() / try_next() / try_collect() / try_for_each() / metadata() / size_hint() plus the adapters map, try_map, filter, filter_map, take, take_while, skip, skip_while, inspect, count, and nth. It also implements futures::Stream (async builds) or Iterator (sync builds) — one or the other, selected by feature, never both at once — so it drops into the combinators you already use.

That one type is also what every framing produces — the Decoder and its companion trait are what differ, not the handle. Pick a framing by pairing a decoder marker with the trait you implement:

FramingOutputDecoderCompanion trait
Server-Sent EventsStreamIterator<T>SseDecoderDecodeSse
Whole documents — a root JSON array, whitespace-separated JSON, lines/NDJSON, RFC 7464StreamIterator<T>DocumentStreamDecoder<Framing>DecodeDocuments, or a strategy
HTTP chunkedStreamIterator<T>ChunkedDecoderDecodeChunked
Raw bytesBinaryStreamBytestreamDecoderDecodeBytestream
A resumable downloadFileStream<Headers>ResumeDownloadDecoderResumableDownload

Two decoders do name an alias, because their handle carries a contract of its own: FlatMapIterator<T> and ResumableIterator<T> are StreamIterator<T> with the metadata slot closed, and their items stay QueryError-fallible because those streams issue follow-up requests. Paginated endpoints likewise use PagedIterator<P>.

A worked SSE stream

grok’s Chat Completions stream is an OverrideEndpoint over the buffered chat request: it swaps the decoder, asks for text/event-stream, and sets stream: true in the body. The companion DecodeSse decodes one event — its members are associated functions, each handed what it needs: the config for response_specs, the head and the config for metadata, and event, head, config, and context for decode_event:

impl<T: StreamableChat> OverrideEndpoint for Stream<T> {
    type Endpt = T;
    type Output = StreamIterator<ChatResponseChunk>;
    type Error = GrokError;
    type Decoder = SseDecoder;
    type Requires = <T as Endpoint>::Requires;
    // ... endpoint_ref / endpoint, accept = "text/event-stream", body with `stream: true` ...
}

impl<T: StreamableChat> DecodeSse for Stream<T> {
    type Item = ChatResponseChunk;
    type Metadata = ();

    // The fixture plan: one model per event, `[DONE]` a protocol marker rather
    // than a payload, and the error envelope a pre-stream 4xx carries.
    fn response_specs(config: &Self::ApiConfig) -> Option<StreamSpecs> {
        let codec = config.json_lib().erase();
        Some(EventStreamSpec::sse::<ChatResponseChunk>(codec)
            .with_sentinels(&["[DONE]"])
            .with_error_envelope::<GrokErrorWire>(codec))
    }

    fn decode_event(
        event: ServerSentEvent<String>, _headers: &ResponseHead,
        config: &Self::ApiConfig, _context: ApiContext,
    ) -> Result<Option<Self::Item>, ResponseError<Self::Error>> {
        match event.data {
            Some(data) if data == "[DONE]" => Ok(None),   // terminal sentinel: consume it, the stream ends with the body
            Some(data) => Ok(Some(config.json_lib().decode_from_slice(data.as_bytes())?)),
            None => Ok(None),                              // a comment-only or metadata-only event
        }
    }

    fn decode_error(
        headers: ResponseHead, body: Vec<u8>, config: &Self::ApiConfig,
        _fallback: ResponseError<Self::Error>,
    ) -> ResponseError<Self::Error> {
        crate::decoders::build_api_error::<Self::Error>(&body, config, headers.status)
    }
}

Three defaulted members round out DecodeSse. type Metadata (with fn metadata(headers, config)) is the per-stream value the handle’s metadata() returns — header-derived information such as rate limits, () by default. validate_response admits a response by media type: text/event-stream in any case and with any parameters, so Text/Event-Stream; charset=utf-8 passes — override it for a service that mislabels its stream. And the spec builder has two more knobs beside with_sentinels: with_events(&[("name", Shape)]) declares a distinct payload model per event name, and without_error_envelope() closes the plan for a service whose pre-stream errors carry no model.

Consuming it is the same as any stream handle:

let mut stream = api.query(request.stream()).await?;
while let Some(chunk) = stream.try_next().await? {
    if let Some(text) = chunk.first_text() {
        print!("{text}");
    }
}

Streaming a buffered format: document streams

JSON is a buffered format — a codec decodes one complete document from a slice — and yet APIs stream it all the time: a large export served as one […] array, Docker’s progress objects one after another, NDJSON. Streaming such a body is a framing question, not a codec question: cut the body into complete documents, and hand each one to the codec you already hold. DocumentStreamDecoder<Framing> does the cutting; you decode.

The framing is a type. JsonArray consumes the [, the commas, and the ] and delivers the elements as they complete, so a multi-megabyte array streams element by element. JsonDocuments delivers whitespace-separated values — which includes NDJSON, with or without its final newline. Lines delivers one document per terminated line and skips blank keep-alives. JsonSeq frames RFC 7464 text sequences. The framer works from JSON’s lexical grammar alone (strings and their escapes, brackets, whitespace); it never validates or decodes a document, so any JSON codec fits, and none is named by the framework.

The decode step comes from one of two places, exactly as it does for BodyDecoder<S>. An endpoint can implement DecodeDocuments itself — the DecodeSse shape, with decode_document(frame: &[u8], headers, config, context) receiving one complete document. More often a client crate writes one strategy and every document stream in the crate becomes a declaration:

pub struct ViaJsonLib;

impl<Endpt, T> DecodeDocumentStrategy<Endpt> for ViaJsonLib
where
    Endpt: Endpoint<Output = StreamIterator<T>, ApiConfig = ParcelConfig, Error = ParcelError>,
    T: for<'de> WireDecode<'de> + WireShape + Unpin,
{
    type Item = T;
    type Metadata = ();

    fn response_specs(framing: StreamFraming, config: &ParcelConfig) -> Option<StreamSpecs> {
        let codec = config.json_lib().erase();
        Some(EventStreamSpec::framed::<T>(framing, codec).with_error_envelope::<ParcelErrorWire>(codec))
    }

    fn decode_error(headers: ResponseHead, body: Vec<u8>, config: &ParcelConfig,
                    _fallback: ResponseError<ParcelError>) -> ResponseError<ParcelError> {
        build_api_error::<ParcelError>(&body, config, headers.status)
    }

    fn decode_document(frame: &[u8], _headers: &ResponseHead, config: &ParcelConfig,
                       _context: ApiContext) -> Result<Option<T>, ResponseError<ParcelError>> {
        Ok(Some(config.json_lib().decode_from_slice(frame)?))
    }
}

impl Endpoint for ExportShipments {
    // ... ApiConfig ...
    type Output  = StreamIterator<Shipment>;
    // ... Error ...
    type Decoder = DocumentStreamDecoder<JsonArray, ViaJsonLib>;
    // ... Requires, method, url ...
}

The item type is whatever the endpoint’s Output names, so the one strategy serves a stream of shipments and a stream of invoices alike; the default strategy, ViaEndpoint, forwards to the endpoint’s own DecodeDocuments impl, the same slot BodyDecoder forwards to DecodeBody. response_specs receives the framing from the decoder — the fixture plan the test framework masks a recording under cannot name a framing the decoder does not cut. Admission rejects a non-2xx response and buffers its body for decode_error, so the pre-stream error path below applies here too; Content-Type is deliberately not checked, since a root array arrives as application/json and line-delimited JSON has several spellings.

Two limits are worth knowing. The framer finds boundaries and trusts them: JSON broken in a way that moves a boundary — an unescaped quote — misframes everything after it, and the stream ends at the codec’s first failure rather than resynchronising. And the frame cap bounds one document, which in the array framing is one element: an element larger than the cap fails where a buffered body of the same size would not.

Pre-stream errors: decode_error

This is the footgun from the decoder chapter. A stream decoder runs the SSE or document framing on the success path, but an error response (a 4xx before any events) isn’t framed data — so override decode_error to run the same shared build_api_error your body decoder uses. Skip it and the error body is silently lost. grok’s SSE, TTS, and download decoders all delegate to the one build_api_error, so a rate-limited streaming call surfaces the same typed error as a non-streaming one. The chunked decoder is the exception: DecodeChunked has no decode_error hook, and its default validate_response admits every status, so a chunked endpoint that can fail before its first chunk handles the status inside decode_chunk.

Binary downloads

A binary download sets Output = BinaryStream and Decoder = BytestreamDecoder, and implements DecodeBytestream. The happy path does zero buffering — bytes flow from the transport straight through the BinaryStream (which implements AsyncRead / Stream) to your consumer:

impl DecodeBytestream for PostTts {
    type Headers = ();   // or a TypedHeader struct to surface response headers
}

Set type Headers to a struct implementing TypedHeader to expose typed response headers (content type, length, custom x-* headers) alongside the byte stream; the decoder parses them off the response head and makes them available via stream.headers() and stream.content_type(). A BinaryStream derefs to the Bytestream underneath, so a content-coded download is decoded on the way in with .with_codec(..) and one of the capie_* decoders.

Resumable downloads

A download that must survive a mid-transfer disconnect sets Output = FileStream (or FileStream<Headers> for typed response headers), Decoder = ResumeDownloadDecoder, and implements ResumableDownload; the framework supplies everything else. grok’s pre-signed content URLs are served by object storage that honours Range, so its download is the shape:

impl Endpoint for DownloadContent {
    type Output = FileStream;
    type Decoder = ResumeDownloadDecoder;
    // ... GET on the absolute URL, Auth::NONE
}

impl ResumableDownload for DownloadContent {
    type Headers = ();

    fn decode_error(headers: &ResponseHead, body: &[u8], config: &Self::ApiConfig,
                    _fallback: ResponseError<Self::Error>) -> ResponseError<Self::Error> {
        crate::decoders::build_api_error::<Self::Error>(body, config, headers.status)
    }
}

On a break the driver re-sends the endpoint with Range from the absolute byte offset already delivered and If-Range carrying the validator it captured from the opening response, and it requires a 206 Partial Content continuing exactly there — a resource that changed mid-download, or a server that answers 200, ends the stream rather than splicing bytes. An opening request that itself carried a Range resumes relative to that base and completes at the end of what was requested. The trait’s defaulted members tune the policy: max_retries, max_reconnects, backoff(attempt, rng), idle_timeout, resume_conditional (which validator the resume guards with), and prepare_for_resume(&mut self, opening_headers) for an endpoint that must adjust itself before the second request. The FileStream it yields reads chunk by chunk (next_chunk, try_next_chunk, into_chunks() for a StreamIterator<Vec<u8>>), reports total_len() and suggested_filename() from the opening response’s FileStreamMeta, and on std sinks itself with write_to(writer), write_to_with_progress(..), or download_to_file(path). S3’s GetObjectResumable is the same decoder over an authenticated, signed endpoint.

The frame cap

The framing engine enforces a per-frame ceiling, so one malformed frame cannot grow without bound. The default is 32 MiB (32 << 20), read back with capi_base_decoders::max_frame_bytes(); it is process-global, set once at startup:

capi_base_decoders::set_max_frame_bytes(64 << 20);   // raise to 64 MiB; 0 = unlimited

A frame past the limit fails with capi_base_decoders::StreamError::FrameTooLarge { limit } (the full path matters: the prelude’s StreamError is capi_core’s, a different enum with only IncompleteEof). It’s global, not per-endpoint (and a no-op on targets without pointer-width atomics, fixed at 32 MiB), so set it deliberately and once.

The layers underneath

Three layers cooperate, and knowing the split helps when debugging: the transport delivers bytes as a Bytestream; a shared framing engine slices them into frames (respecting the cap); and your companion trait turns one frame into one Item. You only ever write the top layer — decode_event / decode_document / decode_chunk — and the engine handles buffering, the cap, and back-pressure.

The next chapters compose on this foundation: pagination is a stream of pages, and resumable streams wrap one in a reconnect policy.

Pagination

A paginated endpoint is an ordinary body endpoint whose response type knows how to ask for the next page. You get two consumption modes for free: query one page like any endpoint, or call .paged() for an auto-fetching stream over all of them.

Two ways to consume

// One page — a normal query. You handle next_page tokens yourself.
let page = api.query(ListShipments::new()).await?;

// All pages — an auto-fetching StreamIterator.
let mut all = api.query(ListShipments::new().paged()).await?;
let everything: Vec<_> = all.try_collect().await?;

.paged() — the IntoPaged trait, blanket-implemented for every endpoint whose output paginates — turns the endpoint into a Paged<T> wrapper whose Output is a PagedIterator<P>, a StreamIterator handle, so it consumes with the same next() / try_next() / try_collect() / take() you already know. It refetches the next page automatically when the current buffer drains. The bounds say what qualifies: T: Endpoint + Clone + DecodeBody, T::Output: PaginatedResponse<T>, and a response-shaped lane (Requires: Capability<Response = Response>).

Implementing PaginatedResponse

To make a response paginate, implement PaginatedResponse<Endpoint> on the output type — three required methods, plus a defaulted total() for an API that reports one:

impl PaginatedResponse<ListShipments> for ListShipmentsOutput {
    type Item = Shipment;
    type Metadata = ();
    fn items(&mut self) -> Vec<Self::Item> {
        core::mem::take(&mut self.shipments)          // hand off this page's items
    }
    fn next_page(&self, current: &ListShipments) -> Option<ListShipments> {
        self.next_cursor.clone().map(|c| {            // None ends the stream
            let mut next = current.clone();
            next.cursor = Some(c);
            next
        })
    }
    fn metadata(self) -> Self::Metadata {}            // whatever is neither an item nor continuation state
}

metadata(self) hands out everything on the page that is neither an item nor continuation state — a total count, a rate-limit snapshot — and the iterator exposes it through metadata(); () when there is none.

The load-bearing detail is the signature: next_page(&self, current: &E) -> Option<E> takes the current endpoint and returns the next one (or None to stop). It gets the current endpoint because the next request is almost always the same query with one field changed — a cursor, an offset, a page number.

No cycle guard. A next_page that never returns None — a server cursor that doesn’t advance — produces an infinite stream. Make sure your stop condition actually triggers.

The next_page body is where the pagination scheme lives, and it’s just Rust:

  • Cursor / continuation token (the common case) — copy the response’s next_cursor / continuation_token into the next request. grok’s custom_voices and S3’s ListObjectsV2 both work this way.
  • Offset / page number — increment an offset or page field until a page comes back short or empty.
  • RFC 5988 Link header — parse rel="next" from the Link header and follow the absolute URL (via an absolute-URL endpoint). capi_base_decoders::LinkHeader is the parser: LinkHeader::of(&headers).next(&base) (or .rel("next", &base)) resolves the relation against the request’s URL and hands back a UrlBuilder.
  • A continuation in the response headers — Azure Tables carries its cursor in x-ms-continuation-NextPartitionKey; next_page sees only the decoded body, so the body decoder lifts the header values onto the page type first, and next_page reads them from there.

Custom decoding: DecodePaged

PaginatedResponse covers the common case where the page is a plain decoded body and the continuation lives in it — Paged<T> already snapshots the endpoint as its State and hands it to next_page as current. Reach for DecodePaged when the page needs the response headdecode_paged receives it beside the body, so a header-driven continuation needs no lift onto the body type — or when the page is not the plain decoded output: a custom Page type, or a different NextPage. Like every decoder it has a state() snapshot, captured before body() consumes the endpoint and fed into decode_paged(...), which builds the next-page endpoint. A DecodePagedSimple variant covers the straightforward subset without the full ceremony.

Consuming a Paged<T>

Because a Paged<T> yields a StreamIterator, the whole streaming vocabulary applies: try_next() for a page-lazy loop, try_collect() to gather everything, take(n) to cap the fetch, and size_hint() (backed by the response’s total() when the API reports one). Items stream out as pages arrive — you never wait for the last page to start processing the first.

Every follow-up page rides the detached FollowUpChannel the decoder carried out of the first query, so it runs the same traversal: the config’s members, the seal, the request budget, the recorder, and the endpoint’s rate_limit — which is re-resolved per send, so a shared limiter paces the pages too. A wire error on a later page arrives inside QueryError as a boxed client error, since the concrete client type is behind the channel. And a derived query input can target the paged flow directly: an IntoEndpoint whose type Endpoint = Paged<ListShipments> and into_endpoint ends in .paged(), with Remap = remap::Yes, maps the PagedIterator lazily across every follow-up page (consuming and patching).

Next: Switching Outputs, where one endpoint yields one of several output types.

Switching Outputs: OverrideEndpoint, SwitchDecoder, and Deferred Polling

One endpoint, more than one possible output. A chat call can return a whole response or a token stream; a request can succeed immediately or return a poll handle; an icecast URL can be a stream or a playlist. The instinct is to return a big enum the caller must match. Capi does something better: it switches the output type, so the type system carries the choice and there’s no enum at the call site.

There are three axes to switch along, and recognizing which one you’re on is the whole skill:

AxisWho decidesMechanism
1. You, before sendingyour code picks a variantOverrideEndpoint (.stream(), .deferred())
2. The server, in its responsethe response’s shape decidesSwitchDecoder / SwitchDecode
3. Fetch strategyone page vs. all pages.paged() (Pagination)

Axis 1: OverrideEndpoint — you switch

An OverrideEndpoint wraps a base endpoint and reshapes it — overriding the body, the accept header, the output, and the decoder — before the request is sent. grok’s .stream() is the canonical case: it takes the same chat request and returns a streaming variant.

let response = api.query(chat.clone()).await?;       // Output = ChatResponse
let mut stream = api.query(chat.stream()).await?;    // Output = StreamIterator<ChatResponseChunk>

.stream() returns a Stream<Self> wrapper that implements OverrideEndpoint, splicing stream: true into the body and swapping in SseDecoder + accept: text/event-stream. You chose the streaming output, at the call site, by calling a different method — no enum comes back.

What may be wrapped is restricted by a sealed marker trait: only types implementing the crate’s private StreamableChat / DeferrableChat bound can be .stream()’d or .deferred()’d, so the switch is available exactly where it makes sense and nowhere else.

Axis 2: SwitchDecoder — the server switches

Sometimes you can’t know the output until you see the response. icecast’s connect URL might serve an audio stream or a playlist, distinguished by Content-Type and the first few bytes. SwitchDecode inspects the response — without consuming it — and picks a sub-endpoint to decode with:

impl SwitchDecode for Connect {
    const PEEK_BYTES: usize = 64;    // how many leading body bytes to peek
    fn select(&self, headers: &ResponseHead, peek: &[u8])
        -> Branch<IcecastConfig, Resolved, IcecastError>
    {
        if playlist::is_playlist(headers, peek) {
            let mut playlist = GetPlaylist::new(self.url.clone());
            playlist.include_auth = self.include_auth;
            Branch::of(playlist)               // decode as a playlist
        } else {
            Branch::of(self.to_get_stream())   // decode as a reconnecting ICY event stream
        }
    }
}

Branch::of(sub_endpoint) decodes the response with that sub-endpoint’s own decoder (its Output must be Into<T>, its Error Into<Error>), and the sub-endpoint must share the switch endpoint’s ApiConfig and Requires — it decodes the switch’s own exchange, which is what lets the capability’s typed extension pass through to whichever branch is chosen. The switching endpoint itself is Clone, because the decoder captures it as its State before the request consumes it. The peek is genuinely non-consuming — the framework reads PEEK_BYTES, then reassembles the body so the chosen branch sees the whole thing. And it never re-issues the request: one round trip, then a local decision about how to read the bytes already in hand.

The enum-vs-switch tradeoff

Both a returned enum and a decoder switch can model “one of several shapes”. The switch wins when the caller knows statically which shape it wants (grok’s .stream() on Chat Completions and on the Responses API — you asked for a stream, you get a stream, no match arm for the non-stream case; both are sealed marker traits, StreamableChat and StreamableResponse, over the endpoints that support it). A returned enum wins when the caller genuinely must handle either at runtime with no way to know in advance. SwitchDecode is for the case where the server decides but each branch is still a distinct, fully-typed output — the caller matches on Resolved, but each arm is a real decoded value, not a bag of Options.

Deferred polling

A deferred operation is an OverrideEndpoint whose output is another endpoint. The wrapper is the client’s own — grok’s Deferred<T> — not a framework primitive: its .deferred() sets deferred: true and returns a poll endpoint rather than a response:

let poll = api.query(chat.deferred()).await?;   // Output = GetChatDeferredCompletion (a pollable endpoint)

The poll endpoint’s decoder is 202-aware — on 202 Accepted it returns DeferredChatResponse::Pending, otherwise it decodes the finished Success(ChatResponse). You then poll it until it’s ready, which is exactly what a wait-until runner automates: hand the poll endpoint to a WaitUntilRunner and it loops with backoff until Success, using the injectable clock so tests stay deterministic.

A note on IntoEndpoint

A fourth composition primitive, IntoEndpoint, converts your own input type into an endpoint (with an optional output remap via MapOutput and remap::{Yes, No}). It’s the machinery behind derived query inputs and the inner leg of flat-map; positioned against this chapter, OverrideEndpoint reshapes an existing endpoint’s request/response, while IntoEndpoint adapts a foreign input into one.

Next: composing streams — resumable reconnection and flat-map, the patterns that prove the decoder layer scales.

Composing Streams: Resumable and FlatMap

The two most sophisticated decoders in the framework — auto-reconnecting resumable streams and flat-map over a stream of streams — are the proof that the decoder layer composes. Both are how the icecast_connect client stays connected to an internet radio mount for hours across network hiccups. The lesson underneath: you write synchronous policy; the framework owns the async machinery.

Reconnection lives at the decoder layer on purpose. It is the only layer that persists for the whole life of stream consumption: a pipeline member returns before the body streams, and a runner returns once the output handle is produced. Only the decoder knows how far the stream got — the byte offset, the last event id, the candidate it was on — and therefore how to resume without gaps or duplicates.

Resumable streams

A Resumable stream reconnects itself when the connection breaks, transparently, so the consumer sees one uninterrupted ResumableIterator<Item> — a StreamIterator whose metadata slot is closed and whose items stay QueryError-fallible, because the stream issues follow-up requests. You implement two traits and a synchronous policy; the framework runs the reconnect manager.

  • DecodeResumable is the connection decode: a Parser (a StreamParser for the framing), an Item, parser(headers, config), and decode_item. It is implemented on the initial endpoint and on its resume endpoint, returning the same decode_item, so the decode is defined once and shared. A blanket gives every such endpoint a plain streaming decoder, StreamFnDecoder, so a resume endpoint is a genuine, independently callable streaming endpoint.
  • Resumable adds the reconnect policy: the Cursor and ResumeEndpoint types, and four all-synchronous hooks.

icecast’s GetStream is the shipped model. Its cursor is a Failover — the index into the playlist’s candidate mounts — and its on_break decides three layers at once:

impl Resumable for GetStream {
    type ResumeEndpoint = Self;
    type Cursor = Failover;

    fn on_break(&self, ctx: BreakCtx<'_>, cursor: &mut Self::Cursor, rng: &ApiRng) -> Resume<Self> {
        let policy = self.reconnect.clone().unwrap_or_default();
        let candidates = self.candidates();

        match policy.decide(ctx.end, ctx.attempt) {
            ReconnectDecision::Stop => Resume::Stop,
            // Layer 1: rejoin the current candidate with the policy's backoff.
            ReconnectDecision::Reconnect => Resume::reconnect(self.at(&candidates, cursor.index))
                .after(policy.backoff(ctx.attempt, rng)),
            ReconnectDecision::Fail => {
                // Same-mount retries are spent. Layer 2: fail over to the next
                // playlist candidate, if any, before surfacing the failure.
                if cursor.index + 1 < candidates.len() {
                    cursor.index += 1;
                    Resume::reconnect(self.at(&candidates, cursor.index))   // a fresh mount: no backoff
                } else {
                    match ctx.end {
                        StreamEnd::Eof => Resume::Stop,   // every candidate spent on a clean end
                        _ => Resume::Fail,                // anything else surfaces the error
                    }
                }
            }
        }
    }

    fn idle_timeout(&self) -> Option<Duration> {
        self.idle_timeout
    }
}
// type Decoder = ResumableStreamDecoder;

on_break is the unified hook, called for every terminal. It receives a BreakCtx — the StreamProgress so far, attempt (reconnects already tried for this break, 0 the first time; it resets on a successful reconnect), and end, which distinguishes a clean EOF, a mid-stream error, a truncation, and a failed reconnect attempt — plus the mutable cursor and the request’s ApiRng, for jittered backoff. It returns a Resume: Resume::reconnect(endpoint), optionally .after(delay) and .keep_buffer(); Resume::Stop for a clean end; or Resume::Fail to surface the error. The partial parse buffer is discarded on a reconnect unless keep_buffer() says otherwise — the right default for rejoining a live feed or resuming at a byte offset, where the bytes before the break are either replayed by the server or gone.

The other hooks fill the cursor and bound the loop:

  • observe(cursor, frame) updates the cursor from each parsed frame, before decoding filters it — a last event id, a response id and sequence number.
  • on_connect(cursor, headers) updates it from each connection’s response head, on the initial connection and after every successful reconnect — a server-assigned resumption token, a Range acknowledgement.
  • validate_continuation(..) (on DecodeResumable) checks that a resumed response answers this request — a 206 Partial Content continuing at the right offset — and routes a rejection through decode_error like an admission failure.
  • max_reconnects() is the framework’s only built-in ceiling, on successful reconnects; it defaults to None, so a policy that always returns Reconnect is an infinite loop by the implementor’s own choice, and ctx.attempt is the cap on consecutive failed ones.
  • idle_timeout() treats a silent connection as a break. Async only: a synchronous stream blocks inside one read, which no timer can interrupt.

A fired cancel token on the request’s context is terminal. Within a connection the transport is the cancel point; between connections the driver is — a break under a fired token ends the stream ahead of the policy, and a backoff wait is cut short. The policy’s verdict is never sought for work the caller has cancelled. Reconnects themselves are observable without being interleaved into the item stream: each one is a ReconnectNotification (with its ReconnectPhase) on the context’s notification channel, which bubbles to a subscriber on the base context or a per-request sender.

Custom stream parsers

Behind a resumable (or any) stream is a StreamParser — a state machine that turns a byte buffer into frames. The rules that trip people up:

fn parse(&mut self, buffer: &mut Vec<u8>) -> Result<Option<Item>, Error>;
fn flush_at_eof(&mut self, buffer: &mut Vec<u8>) -> Result<Option<Item>, Error> { Ok(None) }

Ok(None) means “need more bytes”, not EOF. The engine calls parse repeatedly: Ok(Some(item)) drains one frame, Ok(None) says “I’ve consumed what I can, feed me more”. End-of-stream is signalled by the transport, and then the engine calls flush_at_eof once, for a parser whose last frame may be unterminated — an SSE event with no trailing blank line, an NDJSON line with no final newline. icecast’s IcyParser returns Ok(None) when the buffer is empty or a metadata block isn’t fully buffered yet, and reports is_incomplete() == false because a partial trailing chunk at EOF is normal for a radio stream, never a truncation error. A parser that needs no framing at all — a raw byte relay — is PassthroughParser.

When a plain parser isn’t enough, the escalation ladder is: StreamParser (custom framing) → StreamFnDecoder (the blanket that turns any DecodeResumable into a one-shot streaming decoder) → type Decoder = Self (full control: impl ResponseDecoder<Self> directly on the endpoint, receiving the raw response and the door — the decoder system in depth). Reach up the ladder only as far as you must. Bytestream is read-once: a parser consumes its buffer and cannot rewind.

FlatMap: a stream of streams

FlatMapDecode flattens a two-level stream: an outer source yields items, and each item is turned into an inner endpoint whose stream is spliced into the output — a FlatMapIterator<Item>, the second stream handle with a contract of its own. icecast’s “listen to a playlist” is the archetype: the outer is a looping playlist pager, each entry becomes a bounded GetStream, and the flattened result is one continuous StreamEvent stream:

impl FlatMapDecode for ListenPlaylist {
    type OuterItem = StreamCandidate;
    type Item = StreamEvent;
    type Outer = Paged<RefetchPlaylist>;   // a looping outer: every page re-fetches the playlist
    type Inner = GetStream;                // queried per candidate

    fn outer(&self) -> Paged<RefetchPlaylist> {
        RefetchPlaylist { url: self.url.clone(), include_auth: self.include_auth }.paged()
    }

    fn inner(&self, candidate: &StreamCandidate) -> GetStream {
        // One bounded pass per candidate: connect once, stream until it ends, then let
        // the flatten fail over. Resilience comes from the failover + re-resolve loop.
        let mut endpoint = GetStream::new(candidate.url.clone());
        endpoint.reconnect = Some(ReconnectPolicy::never());
        endpoint.include_auth = self.include_auth;
        endpoint.idle_timeout = self.idle_timeout;
        endpoint
    }

    fn on_end(&self, brk: FlatMapBreak<'_, StreamCandidate>) -> Recovery {
        self.refetch.decide(&brk)
    }
}

Inner is any pass-through IntoEndpoint — a plain endpoint, or a derived input whose conversion can fail; the driver converts each outer item eagerly, and a failed conversion reaches on_end as SourceEnd::InnerUnavailable.

on_end runs whenever a source ends, clean completion included: an inner stream that reached its natural end (InnerCompleted) is as much a decision point as one that failed (InnerFailed), could not be built or sent (InnerUnavailable), or whose outer re-fetch failed (OuterFailed). Outer exhaustion is not a source end — when the outer iterator runs out, the flattened stream simply ends, and looping is the job of a self-refreshing outer such as the paged one above. The FlatMapBreak it receives answers end(), error() (when the source failed), item() (the outer item the inner came from), and the consecutive-failure count. It returns a Recovery:

  • Continue { after } — advance to the next source, swallowing any error, optionally after a delay;
  • Report { after } — emit the error as an item, then advance;
  • Stop — emit the error as the final item, then end.

The default is policy::fail_fast — stop on the first error of any kind, advance on a clean end — and policy::skip_inner_stop_outer is the natural playlist policy: a dead track is skipped and reported, a dead playlist ends the stream. Inner queries derive their context from the originating request’s, so cancelling the flattened stream reaches the inner connections.

Composition is the payoff

The power is that these nest. In the listen example there are three layers of resilience, each owning its concern, and each a small synchronous decision:

  • Layer 1 — same-mount rejoin: GetStream’s own on_break reconnects to the candidate it was on, with the policy’s backoff — when the endpoint is used on its own. Under the flatten, inner sets ReconnectPolicy::never(), so a candidate is one bounded pass.
  • Layer 2 — candidate failover: the same on_break, out of same-mount retries, bumps the Failover cursor to the next mount in the playlist and reconnects there at once.
  • Layer 3 — playlist re-resolve: on_end’s RefetchPolicy, with deterministic backoff, decides when the looping outer fetches the playlist again for a fresh candidate list.

The framework composes them into one StreamIterator that survives a dropped packet, an expired stream URL, and a rotated playlist without the consumer writing a line of reconnect code. A resumable downloadRange at the last byte offset, If-Range on the validator — is the same Resumable machinery packaged as ResumeDownloadDecoder, in the streaming chapter. That’s the whole argument for putting reconnection at the decoder layer.

Next: Batching — several endpoints in one multipart/mixed request, each decoded by its own decoder.

Batching: multipart/mixed Envelopes and Changesets

Some services accept several requests in one: a multipart/mixed body whose parts are complete HTTP requests, answered by a multipart/mixed body whose parts are complete HTTP responses. Google’s batch format and its relatives do this as a flat list; OData does it as a nested changeset the service applies as one transaction. capi_http_batch is the machinery for both — and, like every capability in the framework, it appears only where a service opts in.

Reusable machinery, service policy

The engine is generic: capture each member request, merge the captures into one envelope, send it, split the reply back apart, and decode each part through its own member’s decoder. What is not generic is whether a service accepts such an envelope at all, and in what shape. So the entry point exists only on an interface whose config implements a batch trait:

TraitVerb it enablesOutput
BatchConfigbatch_url(), batch_profile()api.batch(members, &overrides, &rng)a flat envelope; parts fail independently, so Vec<Result<E::Output, _>> in request order
ChangesetConfigchangeset_url(), changeset_profile()api.changeset(members, &overrides, &rng)a nested group the service applies as one transaction; all-or-nothing, so Vec<E::Output> against a ChangesetError naming the member the service blamed

A config implements exactly the traits its service honours. A service that only takes changesets never grows a batch(..) that would post an envelope it rejects — the Azure Table service is one, so azure_tables_capi_rs implements ChangesetConfig alone.

The second gate is on the endpoint. Only types marked Batchable ride in a batch, and there is deliberately no blanket impl: the marker is the service’s explicit per-endpoint decision, written in its own crate as impl Batchable for GetFile {}. Its supertrait structurally excludes streaming outputs — a finite envelope cannot contain an open connection.

The lifecycle

  1. Arm. api.batch(vec![member, ..], &overrides, &rng) finalizes every member through the framework’s capture surface — the config’s pipeline run, credentials and signatures resolved, everything short of transport — and merges the results into one multipart/mixed body of application/http parts, with boundaries drawn from the RNG handed in.
  2. Execute. The armed value is an honest endpoint: api.query(batch) sends it. The caller’s own members, budget, rate policy, and fixture recording behave exactly as for any request, and the envelope authorizes exactly as any request does.
  3. Decode. The response envelope is split positionally — Content-ID echoes asserted where the profile expects them — and each part decodes through its own member’s decoder, yielding per-member results in request order.
let batch = api.batch(vec![GetFile::new("a"), GetFile::new("b")], &RequestOverrides::default(), &rng)?;
let results: Vec<Result<FileOutput, _>> = api.query(batch).await?;

The profile

How the parts are written is the service’s wire contract, captured in a BatchProfile the config returns: the part content type (application/http), whether inner request lines are path-only (GET /v1/files/x, the Google form, with the envelope’s URL supplying scheme and authority) or absolute, how response parts map back to members, where a member’s credential rides, the Content-ID scheme, the per-part Content-Transfer-Encoding, and the boundary prefixes. The type is #[non_exhaustive] and built with with_ methods from a default that matches the Google shape, so a profile names only its departures. Azure’s changeset departs in four:

const TABLE_CHANGESET: BatchProfile = BatchProfile::new("application/http")
    .with_path_only_requests(false)                          // absolute inner request lines
    .with_content_id_scheme(ContentIdScheme::Bare { first: 1 })
    .with_binary_transfer_encoding()
    .with_boundary_prefixes("batch_", "changeset_");         // the spelling the emulator derives its replies from

impl ChangesetConfig for TablesConfig {
    fn changeset_url(&self) -> Result<Url, UrlError> {
        self.config.base_url().path("$batch").build()
    }
    fn changeset_profile(&self) -> &BatchProfile { &TABLE_CHANGESET }
}

Where the credential rides

By default the envelope authorizes and members are captured bare — MemberAuth::EnvelopeOnly, which is what every surveyed batch facility does. A part then carries no credential in either plane, neither a placed token nor a signature; the armed batch is an ordinary request to the service, so its credential resolves at send time through the ordinary pipeline, with the caller’s scope (with_auth, as_role) applying there. MemberAuth::PerMember is the minority mode for a facility that accepts a per-call credential: it places one inside each part in addition to the envelope’s.

A capture is not a wire exchange, and that draws two lines. Caller-scoped conduct — members, budgets, throttles — applies to the envelope send, never to member captures; a part is what the solo send would produce minus the caller’s own members. And a challenge-driven signer (capi_auth_digest) under PerMember must be pre-armed by a prior exchange before its endpoints join a batch, because a capture signs once and has no response to answer a challenge on. Under the default, nothing is placed at capture and the envelope send is a real exchange the signer can answer on. A payment runner (capim_x402) reaches neither a capture nor the config’s pipeline: seat it on the api.query(batch) call and it pays for the envelope. This is also the one legitimate use of RequestOverrides::no_signing(): a member captured into an envelope that authorizes for the whole group has nothing to sign.

Wrapping it for one service

Nothing here is sealed against a service dressing it up. A profile plus a branded entry point bounded on the service’s own marker gets a long way; a service’s own armed endpoint type needs only ArmedBatch (so BatchEnvelopeDecoder decodes for it), MergedBatch::build plus from_merged for construction, and an IntoBatch impl for the collection newtype — and the changeset side is the same seam for seam.

A signing service needs the branded type. The generic armed types leave Endpoint::signer at its default — whether a service signs is that service’s fact, not the machinery’s — so an envelope built from them goes out unsigned. One signer line on the service’s own armed type declares the envelope’s signature, and it is the only place it can be. That is why azure_tables’ entry point is api.transaction(ops) rather than the generic changeset(..): its TableTransaction is the armed changeset with the SharedKey signer declared, and api.query(transaction) yields one TableOpResult per member or a ChangesetError whose failed_member is the zero-based index the service blamed. Only a TableOp rides it; the read endpoints carry no membership marker, which is what keeps a query structurally outside a transaction.

That closes Advanced Response Patterns. The next part turns to the exchanges that are not one request and one response at all — WebSocket first.

WebSocket: Connections and Sessions

A WebSocket is a bidirectional, long-lived connection — yet in Capi it’s modeled as a perfectly ordinary endpoint. The difference is entirely in the associated types: the Output is a live connection, the Requires capability makes the framework write the handshake and the adapter perform the upgrade, and the Decoder validates the 101 and hands the upgraded connection to you. capi_websocket itself is a plain dependency with no gating feature of its own; the websocket feature lives on the adapter that can perform the upgrade, and a client library conventionally gates its WebSocket endpoints behind a websocket feature that pulls the crate in — the client capability table says which adapters can, on which lanes.

The endpoint

#[capi]
#[derive(Debug, Clone, WireModel, ApiEndpoint)]
#[endpoint(path_template = "/v1/realtime")]
pub struct ConnectRealtime {}

impl Endpoint for ConnectRealtime {
    type ApiConfig = GrokConfig;
    type Output = TypedWsConnection<GrokConfig, RealtimeServerMessage, RealtimeClientMessage>;
    type Error = WebSocketDecodeError<RealtimeMessageError>;
    type Decoder = WebSocketDecoder;
    type Requires = WebSocket;                          // ← the whole upgrade declaration
    fn method(&self) -> Method { Method::GET }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path(self.path_string())
    }
}

impl DecodeWebSocket for ConnectRealtime {
    type In = RealtimeServerMessage;    // what the server sends you
    type Out = RealtimeClientMessage;   // what you send the server
    fn session_spec(config: &Self::ApiConfig) -> Option<WsSessionSpec> {
        Some(text_session_spec::<Self, _>(config.json_lib()))
    }
}

type Requires = WebSocket is the entire upgrade declaration — no directive, no extra method. The WebSocket capability’s prepare_request hook writes Connection: Upgrade, Upgrade: websocket, Sec-WebSocket-Version: 13, and a fresh Sec-WebSocket-Key automatically. The URL is an ordinary https one — the capability, not the scheme, says the exchange is an upgrade. Name the two message types on DecodeWebSocket; session_spec describes the per-message schema for the fixture tooling.

Subprotocols

A service that names a subprotocol gets it from DecodeWebSocket::subprotocols, which returns the entries offered in Sec-WebSocket-Protocol in preference order:

fn subprotocols(_config: &Self::ApiConfig) -> Vec<HeaderValue> {
    vec![HeaderValue::from_static("realtime")]
}

Offer only entries the service accepts: a browser fails the connection unless the server echoes back exactly one of them.

Credentials do not belong here even though the config can reach the auth store. This hook runs while the request is assembled, and the seal resolves the credential later, at the moment of use, renewing it through its seat when it is near expiry — a token read here would be the one held before that resolution. A credential lives in the store, and the endpoint’s auth() says to offer it in the same list:

fn auth(&self) -> Auth {
    Auth::DEFAULT.append_header(SEC_WEBSOCKET_PROTOCOL, "xai-client-secret.")
}

That one is placed by the seal’s resolution, appended after the entries above, while the API’s HTTP endpoints keep drawing the same secret through their own Authorization header — see the auth model.

Using the connection

TypedWsConnection<Config, In, Out> is a typed duplex channel:

let mut conn = api.query(ConnectRealtime::new()).await?;
conn.send(RealtimeClientMessage::ResponseCreate).await?;   // Out: a unit variant
while let Some(msg) = conn.recv().await {                         // Option<Result<In, WsError>>
    match msg? { /* handle a RealtimeServerMessage */ }
}
conn.close(None).await?;

send takes your Out type, recv yields your In type, and each message is encoded / decoded through the config’s format-typed Codec via the per-message EncodeWsMessage / DecodeWsMessage traits. (A binary protocol can bypass the codec entirely.) Ping/Pong frames are handled for you; a Close routes through the close handshake. negotiated_subprotocol() reports the entry the server echoed back, when it echoed one.

The drain gotcha. recv() returns None when the connection is cleanly closed — but drain on the protocol’s terminal message, not on None. On the raw WsConnection a clean close arrives as Some(Ok(WsMessage::Close(..))) before the final None, and an abrupt transport EOF surfaces first as Some(Err(..)). On the typed connection a peer’s Close is fed through your In decoder first, so it reaches you as whatever that decoder makes of a WsMessage::Close — an error, in the usual impl — and then None. Either way, loop until your protocol’s own end-of-session signal, treating None as “the socket is gone”, not as your success condition.

The WsMessage enum carries the five frame kinds — Text, Binary, Ping, Pong, Close(Option<WsCloseFrame>) — for code that works at the raw frame level.

Split mode

To send and receive from different tasks, split the connection:

let split = conn.into_split()?;         // → TypedWsSplit { controller, recv, send }

TypedWsSplit gives you independent send and recv halves plus a WsController whose run() drives the underlying socket. This is the shape you want for a full-duplex session where a reader task and a writer task operate concurrently. It is async only: a blocking build has no tasks to hand the halves to, so there into_split() returns Err(WsSplitError::Unsupported).

Sessions and tunneled requests

A socket that carries more than one conversation at a time needs three things a bare TypedWsConnection does not provide: a way to say what an arriving frame is, a place for the state the server hands out mid-session, and correlation keys for the requests this side sends. The session feature supplies the shapes and asks the client for the meaning — as one pure trait impl, so no #[cfg(feature = "async")] twin ends up in the client.

WsSessionProtocol is the client’s entire contribution: a plain value holding whatever the server has told you, with pure methods.

impl WsSessionProtocol for NotificationProtocol {
    type Config   = MyConfig;
    type Incoming = ServerFrame;          // your existing DecodeWsMessage type
    type Outgoing = ClientFrame;          // your existing EncodeWsMessage type
    type Key      = String;               // whatever your protocol spells ids as

    fn mint_key(&mut self, sequence: u64) -> String { sequence.to_string() }

    fn classify(&self, frame: &ServerFrame) -> Classified<String> {
        match frame {
            ServerFrame::Response(rsp) => Classified::Reply { key: rsp.id.clone(), terminal: true },
            _ => Classified::Notification,
        }
    }

    fn absorb(&mut self, frame: &ServerFrame) -> Absorb<ClientFrame> {
        if let ServerFrame::Ready(ready) = frame { self.wsc = Some(ready.token.clone()); }
        Absorb::Nothing                    // or Absorb::Send(frame) for an answer you owe on sight
    }
}

classify reads the frame’s own fields — there is no table of outstanding requests to consult; the session keeps none and the caller correlates — and answers one of four arms: Reply { key, terminal } (with terminal: false while more frames under the same key are still to come, the ordinary case for a streamed answer), Request (a peer-initiated request the server expects you to answer, read out of the frame yourself), Notification (unsolicited traffic), or Discard (a frame the protocol says to drop, which recv loops past). absorb is where server-given session state lands, in your own struct’s fields, and Absorb::Send(frame) is the answer a protocol owes on sight — a pong for a ping — written before your caller sees the frame that caused it. heartbeat_frame(key) is the optional keepalive the caller paces.

WsSessionMachine holds the ordering logic once, as lane-free code a test can drive with no connection at all; WsSession<Proto> is the pump, written once per lane, each method a short delegation: send(frame), recv() (absorbs, answers, skips discards, and hands back a Received { frame, class }), send_heartbeat(), close(..), and into_parts(), which gives back the connection and the protocol value so what the session absorbed survives a reconnect. The session is an alias, pub type NotificationSession = WsSession<NotificationProtocol>;, never a newtype — a newtype would have to delegate the lane-shaped methods, and the twins would be back. Reach absorbed state through session.protocol().

WsTunnelProtocol is the seam for a socket that carries whole API requests inside its frames. Implement it and the session gains send_request(endpoint): the endpoint runs the framework’s real request pipeline — members, credential freshness, the seal, one-shot signing — the finished request is drained into a PackagedRequest, and your tunneled_frame(request, key) turns it into one of your frames. The route is described once, in the endpoint, and the frame cannot drift from it. Back comes the key the reply will echo, and session.decode_reply::<Endpt>(reply) decodes that reply to the endpoint’s own output with its typed error envelope intact, under a follow-up of the socket’s upgrade context. The body policy is the carrier’s and three-valued — splice a body already in the carrier’s format, escape one that is not (base64url, where the carrier specifies it), refuse what the carrier does not admit, checked where the content type is still in hand. tunnel_overrides() defaults to the posture the carriers share: a socket was authorized when it was opened, so a request captured into one of its frames carries no credential of its own. The packaged request and the packaged response are the capture plane, and they are not WebSocket’s: a queue payload or a batch part packages the same way.

Two things the session deliberately does not do: it schedules no keepalive (the caller sends a heartbeat between receives) and orchestrates no reconnect — each is a layer above the machine. And the connection() escape hatch bypasses absorption: frames read through it never reach absorb, so a resumption token goes missing and nothing says so. Use it for what the session does not cover, and into_parts() when you are done with the session entirely. The test framework records a session as a .req/.rsp/.meta/.ws fixture quad (replay testing).

Native vs. the browser

The handshake happens in different places by platform, and the framework hides the difference:

  • Native — the client adapter performs the upgrade and validates Sec-WebSocket-Accept against the key it sent (using the standard WebSocket GUID). The decoder deliberately does not re-check it — validation already happened where the handshake did. capic_reqwest upgrades on its async lane only: a blocking build compiles, and the send fails with WebSocketRequiresAsync, because reqwest’s blocking client has no upgrade API. capic_ureq upgrades over tungstenite and hands back a message-level connection; capic_native_client performs the genuine wire upgrade on both lanes.
  • The browsercapic_wasm_fetch derives ws:// or wss:// from the endpoint’s http(s) URL at its own wire boundary, the browser owns the handshake and framing entirely, and the connection reaches your decoder as the capability’s typed extension, never through the transport by hand. A refused upgrade surfaces as a connection error from the browser.

Either way, the connection arrives at your DecodeWebSocket as a typed value, and on native a refused upgrade (a 401 or redirect instead of a 101) still flows through the normal decoder path so you can surface it as an error rather than a hang. A WebSocket endpoint issues no follow-up sends of its own: the capability’s follow_up_channel is unsupported().

Next: gRPC, another capability that reshapes the transport — this time with a protobuf codec rather than a socket upgrade.

gRPC

gRPC is the framework’s proof that the model generalizes past JSON-over-HTTP: it’s the wire model with a protobuf format, plus a capability and a decoder that understand gRPC’s length-prefixed framing and its trailer-based status. An endpoint that speaks gRPC looks like every other endpoint — different associated types, same shape.

Scope: unary, client streaming, and server streaming. A unary call sends one message and reads one reply. A client-streaming call sends an ordered source of messages and reads one reply. A server-streaming call sends one message and reads a response of many. All three end in exactly one grpc-status, and what differs is how many frames each direction carries. Bidirectional streaming needs a session-shaped capability, as WebSocket has, and the two shipped adapters are half-duplex by construction; gRPC-Web is a separate protocol, with its trailer block inside the body, and needs its own adapter. capi_grpc’s README carries a conformance table that says this row by row, with the test behind each row.

Protobuf is a Codec format

Protobuf slots into the same contract/codec split as JSON and XML: capiw_ext_protobuf defines the Protobuf format marker, and capiw_prost provides the concrete codec:

let pb: Codec<Protobuf> = capiw_prost::codec();   // content type "application/protobuf"

A gRPC config stores a Codec<Protobuf> and exposes it (via the GrpcConfig trait’s protobuf_codec()), exactly as a JSON config exposes json_lib(). capiw_prost wraps prost’s low-level encoding primitives — not its Message derive — because the codec consumes the wire model’s erased event stream like any other backend.

Authoring the message types

You don’t run protoc. Message types are ordinary Rust structs decorated with #[proto_extension], which expands to the wire derives and lowers protobuf specifics (field numbers, syntax, packing) into the wire model’s extension bag:

/// `capi.test.v1.Number`: one value in the sequence `Accumulator/Sum` is streamed.
#[proto_extension]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Number {
    pub value: u64,
}

/// `capi.test.v1.Summary`: what one `Accumulator/Sum` call adds up to.
#[proto_extension]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Summary {
    pub count: u64,
    pub sum: u64,
}

Field numbers follow declaration order unless #[proto(id = N)] says otherwise. So a protobuf type reaches the codec through the same WireModel binding as a JSON type — the numbers and presence rules ride in the binding’s ext bag, which capiw_prost reads back to drive encode/decode. It’s the wire model all the way down.

The worked example throughout this chapter is grpc_test_capi_rs, the family’s gRPC reference client, and its service capi.test.v1.Accumulator, served by a real tonic peer its tests spawn. Sum takes a stream of Number and answers one Summary of their count and their wrapping sum; Count takes one Number and answers a stream of 1..=upto, one message each.

A unary endpoint

impl Endpoint for HealthCheck {
    type ApiConfig = GrpcTestConfig;
    type Output = HealthCheckResponse;
    type Error = GrpcError;
    type Decoder = GrpcDecoder;
    type Requires = Grpc;
    fn method(&self) -> Method { Method::POST }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path([HEALTH_SERVICE, "Check"])   // /grpc.health.v1.Health/Check
    }
    fn content_type(&self, _config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue> {
        capi_grpc::CONTENT_TYPE
    }
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        let request = HealthCheckRequest { service: self.service };
        Frame::encode(config.protobuf_codec(), &request).map_err(BodyError::new)
    }
}

The gRPC-specific pieces: type Requires = Grpc (the capability), type Decoder = GrpcDecoder, capi_grpc::CONTENT_TYPE for the content type, and Frame::encode(codec, &request) to frame the one message. A Frame holds its bytes, so the request declares an exact Content-Length and stays replayable. Everything else is the ordinary endpoint contract. Like every protocol crate, capi_grpc sits behind no cargo feature of its own — it arrives as a crate dependency beside a protobuf codec; the transport adapters expose a grpc toggle for the lane that can carry it, and a client library conventionally gates its gRPC endpoints behind a grpc feature that pulls the crate in (the client capability table).

A client-streaming endpoint

The same five associated types. What changes is the body: instead of one framed message, the endpoint holds a MessageSource<M> and hands it to FramedBody.

pub struct Sum {
    pub numbers: MessageSource<Number>,
    // the reference client's own carries two more: an early-answer knob and a
    // RequestMetadata, both shown further down
}

impl Endpoint for Sum {
    type ApiConfig = GrpcTestConfig;
    type Output = Summary;
    type Error = GrpcError;
    type Decoder = GrpcDecoder;
    type Requires = Grpc;

    fn method(&self) -> Method { Method::POST }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path([ACCUMULATOR_SERVICE, "Sum"])
    }
    fn content_type(&self, _config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue> {
        capi_grpc::CONTENT_TYPE
    }
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        Ok(FramedBody::new(config.protobuf_codec(), self.numbers))
    }
}

The two halves are separate because they are settled at different moments. The source is what the caller hands the endpoint; the codec belongs to the config and reaches the endpoint only in body(), which is where FramedBody::new pairs them. Keeping the source codec-free is also what lets one definition serve both lanes: MessageSource<M> is the same type sync or async, so the field needs no cfg twin.

Three ways to build one:

MessageSource::from_messages(numbers)                      // an iterator that cannot fail
MessageSource::from_fallible_messages(rows.map(read_one))  // an iterator of Result<M, E>
MessageSource::from_stream(incoming)                       // async lane: a Stream of Result<M, E>

from_messages and from_fallible_messages serve both lanes; from_stream is the asynchronous one (and browser wasm), and its stream is pinned on the heap for you, so a generator needs no Box::pin at the call site. A fallible source’s error is boxed as the cause of the failure the call reports — it stays downcastable to your own type all the way out.

The retryable shape

A source is turned once. Nothing can produce those bytes a second time, so re-sending a client-streaming call means asking the endpoint to describe itself afresh — and the framework says so at compile time rather than at runtime:

api.retry(RetrySpec::new()).query(Sum { numbers })   // does not compile

Every re-issuing runner — .retry(..), .retry_after(..), .refresh(..), .wait_until(..), the redirect-following runner — takes Endpt: Clone, because each attempt calls the endpoint again so body() runs afresh. A MessageSource holds an erased producer and is not Clone, so an endpoint holding one is not Clone either, and the pairing is refused where you wrote it.

To make such a call retryable, hold what the messages are made from and build the source inside body():

#[derive(Clone)]
pub struct SumOf {
    pub values: Vec<u64>,          // Clone: the recipe, not the source
}

impl Endpoint for SumOf {
    // …
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        let numbers = MessageSource::from_messages(self.values.into_iter().map(|value| Number { value }));
        Ok(FramedBody::new(config.protobuf_codec(), numbers))
    }
}

Two seats below the runner can re-send a request without re-describing it, and both pay for it by pinning bytes: a middleware that takes a replay handle (no shipped member does on this lane — follow_redirects declares that it does not serve Grpc), and a signer with a non-zero retry budget, which holds every transmitted byte until the exchange settles. An endpoint that wants neither cost carries a signer with no budget.

A server-streaming endpoint

The same five associated types once more. The request goes back to being one framed message, exactly as a unary call’s is; what changes is the reading half. Output is a MessageIterator<M> and Decoder is GrpcStreamDecoder.

#[derive(Clone)]
pub struct Count {
    pub upto: u64,
    // the reference client's own carries three more: a stop-after knob, a pacing knob,
    // and a RequestMetadata
}

impl Endpoint for Count {
    type ApiConfig = GrpcTestConfig;
    type Output = MessageIterator<Number>;   // one item per message, in wire order
    type Error = GrpcError;
    type Decoder = GrpcStreamDecoder;
    type Requires = Grpc;

    fn method(&self) -> Method { Method::POST }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path([ACCUMULATOR_SERVICE, "Count"])
    }
    fn content_type(&self, _config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue> {
        capi_grpc::CONTENT_TYPE
    }
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        Frame::encode(config.protobuf_codec(), &Number { value: self.upto })
            .and_then(|frame| frame.with_compression(&config.compression()))
            .map_err(BodyError::new)
    }
}

MessageIterator<M> is StreamIterator<M, ResponseHead> — the framework’s one stream handle, with its metadata slot closed over the response head. So it is consumed exactly like every other stream here: inherent next() / try_next() / try_collect() on both lanes, Iterator on the synchronous one and futures::Stream on the asynchronous one.

let mut numbers = api.query(Count::new(5))?;          // sync lane
while let Some(number) = numbers.try_next()? {
    println!("{}", number.value);                     // arrives as the server sends it
}
let mut numbers = api.query(Count::new(5)).await?;    // async lane
while let Some(number) = numbers.try_next().await? {
    println!("{}", number.value);
}

.query() returns when the head has been read, not when the stream has ended — a call that a peer paces yields its first message long before its last is sent. Items are Result<M, QueryError>, because a stream carrying its own transport can fail at any point; your GrpcError is reachable through QueryError::api_error::<GrpcError>().

The metadata slot is the head, trailer cell included, so both blocks read off the handle — the initial one at once, the trailing one after the end. The view is taken fresh each time, because draining needs the handle back:

let request_id = ResponseMetadata::of(numbers.metadata()).initial().ascii("x-request-id");
// … drain the stream …
let trailing = ResponseMetadata::of(numbers.metadata()).trailing();

The status is the stream’s end, not something beside it: an OK status ends it with None, and a non-OK status — after any number of messages — is the stream’s last item, carrying whatever rich status the peer sent. A failure is terminal too, so a stream is over the moment it hands you one. Count holds the value its request is made from rather than a source, so it is Clone and .retry(..) accepts it; what a runner can re-issue is a call refused at the head, since a stream that failed after its first item has no gRPC resumption shape to resume from.

The path one message takes

sequenceDiagram
    participant S as MessageSource
    participant B as FramedBody
    participant T as Transport
    participant P as Peer
    participant D as GrpcDecoder
    T->>B: read — the body is pulled, never drained
    B->>S: one turn
    S-->>B: the next message, exhausted, or failed
    B->>B: encode into the frame buffer, then write the prefix over it
    B-->>T: flag byte, big-endian length, payload
    T->>P: DATA on the HTTP/2 stream
    Note over S,P: repeats as the peer accepts bytes, then END_STREAM at exhaustion
    P-->>D: HEADERS, the reply frame, then the trailer block
    D->>D: unframe, decode through the codec, resolve grpc-status

Read it downward: the transport asks the body for bytes, the body turns the source once, the codec encodes that message straight into the frame buffer behind a placeholder prefix, and the prefix is written over the placeholder once the payload’s length is known. Nothing is produced before it is asked for — Endpoint::body performs no I/O at all, it only describes the request.

Coming back, GrpcDecoder reads the reply body as it arrives, unframes the single message, decodes it through the config’s Codec<Protobuf>, and resolves the status from the trailer block — or, for a trailers-only reply, from the response headers. It reads to EOF because that is when the trailers arrive, and it accumulates nothing past the first frame: a second one is refused from its prefix.

What it costs

  • One frame buffer. The codec encodes into it directly, so framing a message allocates nothing of its own. The buffer keeps the capacity of the largest frame so far, and nothing else: no message is retained once its bytes have been read, and both of the body’s endings release it.
  • No declared length. The encoded size of a message that has not been produced yet cannot be known without producing it, so the request carries no Content-Length and the message sequence is delimited by the end of the stream. That is what gRPC expects.
  • Fused endings. Exhaustion and failure are both final, and neither polls the source again. A failed source never becomes a successful end of request.
  • Compression costs a second buffer. A frame’s prefix carries the length of what is transmitted, so a compressed message must be finished before its prefix can be written: the message exists twice, encoded in a scratch buffer and coded in the frame buffer. Each message additionally pays for a fresh codec — per message, because gRPC finishes every message’s compressed stream on its own. Measured over 500-byte messages, an uncompressed message costs about 2 allocations and 2.5 KiB, and a gzip-compressed one about 10 allocations and 328 KiB, of which 319 KiB is the encoder’s match tables. None of it accumulates.

Nothing here imposes a size ceiling. Three optional maximums exist, each measuring a different quantity — the encoded message about to be sent (FramedBody::with_max_message_size), the length an incoming frame’s prefix declares, which for a compressed frame is its compressed length (GrpcConfig::max_message_size), and what a compressed member expands to (CompressionPolicy::with_max_decompressed_size) — and every one of them is off by default. Your peer is another matter: tonic and grpc-go refuse a received message over 4 MiB by default, with RESOURCE_EXHAUSTED.

The call lifecycle

A call has one sending direction and exactly one final status. The exchange is half-duplex: the response is returned once the request reaches EOF or the server stops the upload. Everything below but the last paragraph governs a request of one message and a request of many alike — the difference is how many frames the request body carries, not how the call ends. A response of many messages is the reading half’s own.

The sending direction ends in one of three ways. Exhaustion — the source reports no further messages and the request ends there; a source of zero messages is a legal call, not a malformed one. Terminal failure — the source, or the encoding of a message it yielded, fails; that failure is the call’s error and never becomes a successful EOF. Cancellation — the request’s deadline or budget ends the exchange and the adapter resets the stream; the send reports it as its own SendError arm (TimedOut, Stalled, Cancelled), distinct from an adapter failure.

The server may answer before the request finishes, and a service that validates its first message often does. Whatever follows that answer, a complete response is the call’s result: the peer may reset the stream with NO_ERROR, reset it with CANCEL, or not reset it at all and let the client upload to exhaustion. In the first two the upload ends where it is and the unsent remainder is not an error; in the third the answer you already hold is still the answer. A reset arriving before the response is complete is a different thing entirely — a transport failure carrying the peer’s HTTP/2 error code, mapped to the status the protocol assigns it.

Deadlines are stated to the server. Both adapters derive grpc-timeout from the request’s deadline and send it, so a long upload can be ended by the server as well as by the client; an application-set grpc-timeout is refused rather than merged. In practice the adapter’s own budget answers first, at the deadline on the asynchronous lanes.

A producer failure reaches you whole. It is not an Endpoint::Error — that type describes what a server said, and nothing was said here. It travels as the request body’s failure: a MessageError naming the zero-based index of the message being produced and carrying your own error under it, reachable by walking Error::source() and downcasting. (One caveat: hyper drops a request-body error on HTTP/2, so the reqwest lane reports the call as an opaque transport failure with nothing of the producer in it. The native client carries it.)

One read, one message — on the synchronous lane. A synchronous read turns the source at most once, which is what transmitting a stream means: a source that takes a second per message sends them a second apart rather than going quiet until the transport’s buffer fills. The cost is that an early answer is acted on at the next message boundary, and that nothing can interrupt a turn of the source — a source that blocks waiting for its next message is unobservable until it returns. The asynchronous lane frames whatever is ready and stops at the source’s own Poll::Pending, so it sees an early answer at once. A source whose individual waits are long belongs on the async lane.

A response of many messages. Before any message, the head decides: a trailers-only status, a non-200 from an intermediary, a 200 that is not gRPC, a grpc-encoding the call did not accept, or a head that cannot carry trailers at all. Each of those is the call’s error from .query() rather than an item — a stream that can never be given a status refuses before it yields, instead of discovering that at EOF. Past the head, a trailers-only OK response is an empty stream: a server with nothing to send may answer with the status alone, and that is the one place the two decoders differ, since for a unary call the same response is a missing message. The status is the end: OK ends the stream with None, and a non-OK status is its last item. So is a failure — bad framing, a limit, a member the codec refuses, a message that does not decode, a read error — after which nothing more is read and the body is released with it. Dropping the handle cancels the call: the body drops, the adapter resets the stream, the peer sees CANCEL, and no status arrives; the drop itself is not an error. The deadline governs the whole stream rather than each message, so either the adapter’s budget ends the read or the server ends the call with DEADLINE_EXCEEDED, whichever lands first. And the request stays one message: pairing this decoder with a FramedBody is bidirectional on the wire, the two adapters do different things with such a call, and no evidence certifies either — the ledger carries it as an unsupported row rather than a refusal.

Per-message compression

gRPC compresses messages, not bodies. Each message is compressed on its own, finished on its own, and carried in a frame whose length prefix counts the compressed bytes with the flag byte set to 0x01. A whole-body Content-Encoding would compress the framing between messages, so the request body stays identity-coded and the adapters add no content-encoding of their own.

The algorithms live in their own crates: capie_gzip for gzip, capie_miniz_oxide for the zlib-wrapped DEFLATE that gRPC calls deflate. capi_grpc owns the flag, the token vocabulary, and the negotiation, and drives whatever codec it is handed:

impl GrpcTestConfig {
    pub fn gzip() -> MessageCoding {
        MessageCoding::gzip(|| GzipEncoder::new(capie_gzip::Level::DEFAULT), GzipDecoder::new)
    }
    pub fn deflate() -> MessageCoding {
        MessageCoding::deflate(|| ZlibEncoder::new(capie_miniz_oxide::Level::DEFAULT), ZlibDecoder::new)
    }
}

The two factories rather than two codecs is the protocol’s doing: a fresh instance per message is what “independent compression context” means.

Compression is the config’s, and an endpoint follows it. A CompressionPolicy names what the call sends and what it can decode, independently:

CompressionPolicy::none()                          // the default: nothing, either way
CompressionPolicy::using(GrpcTestConfig::gzip())   // send gzip, accept gzip
CompressionPolicy::sending(coding)                 // compress out, take plain replies
CompressionPolicy::accepting(coding)               // send plain, decode compressed replies
    .and_accepting(other)                          // in preference order
    .with_bypass_below(512)                        // short messages go out as they are
    .with_max_decompressed_size(8 * 1024 * 1024)   // refused at the bound, not after it

The config states it once, GrpcConfig::grpc_headers() stamps grpc-encoding and grpc-accept-encoding from it, and an endpoint’s headers() is one call over that — so the negotiation is never hand-written at an endpoint:

fn headers(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<HeaderMap, HttpHeaderError> {
    let mut headers = config.grpc_headers();
    self.metadata.stamp(&mut headers);
    Ok(headers)
}

FramedBody::new(..).with_compression(config.compression()) in body() is the sending half; the receiving half reaches the decoder through the config. There are no defaults: a call that names nothing compresses nothing, sends neither header, and refuses a reply frame that claims compression — which is exactly what the protocol says a peer that was told nothing may not send. A peer that was not configured to accept your encoding answers UNIMPLEMENTED with its own grpc-accept-encoding attached, which you can read (see below).

Metadata and rich status

Metadata is the key/value pairs a call carries beside its messages. Outgoing pairs are a RequestMetadata, built once and checked as it is built, so the endpoint holds a checked value and headers() stays infallible:

let metadata = RequestMetadata::new()
    .ascii("x-tenant", "acme")?
    .binary("x-trace-bin", trace_id.as_bytes())?;   // base64, un-padded, under a -bin name

Every name is held to the protocol’s Header-Name production (lowercase), every ASCII value to printable ASCII, and every grpc- name is refused — that prefix is the protocol’s, and grpc-encoding, grpc-accept-encoding and grpc-timeout are placed for you by the config and the adapter.

Incoming metadata arrives in two blocks — the response headers, and the trailer block the status rides in — and ResponseMetadata reads both through one view, off a ResponseHead:

let (head, summary) = api.with_headers().query(Sum { numbers }).await?;
let metadata = ResponseMetadata::of(&head);
let request_id = metadata.initial().ascii("x-request-id");
let elapsed = metadata.trailing().and_then(|block| block.ascii("x-elapsed-ms"));

trailing() is None until the body reaches EOF and the head’s shared trailer cell fills — which for a decoded gRPC call has already happened, because that is where the status lives. A repeated name is an ordered list and ascii() answers with the first of it; a -bin value decodes from base64 padded or un-padded, and is split on , first so a repeated name and the joined form an intermediary may make of it read the same. The reserved grpc- pairs stay reachable rather than hidden — custom() and reserved() separate them.

A failed call is read the same way. with_headers() keeps its head clone on the error arm, so QueryError::response_head() carries a refusal’s head, trailer cell included — which is how you read the grpc-accept-encoding a peer attached to its UNIMPLEMENTED, or the trailing metadata a rejection carries, without dropping to with_raw_response().

A failing call’s status is GrpcError::Status(GrpcStatus), carrying the typed GrpcCode, the percent-decoded grpc-message, and — when the peer sent grpc-status-details-bin — the google.rpc.Status it describes. A detail stays raw, type_url plus bytes, and AnyDetail::decode turns one into whatever message its type names through the config’s codec; the standard google.rpc detail messages are not modeled here, because each is an Any payload like any other and a service that sends one describes it with #[proto_extension]. A details code that contradicts grpc-status is a typed error keeping both, not a silently preferred one.

Recording a gRPC call

gRPC calls record and replay through the same fixtures every other endpoint uses; the recorder needs a gRPC-capable live client attached, which is one step (.with_grpc(client), or .with_bundled_grpc()).

A streaming request body needs one decision, because the two ways of capturing one each cost something:

ReplayConfig::default().capture_streaming(StreamingCapture::Spooled)
  • Refuse (the default) fails the send rather than record a call it cannot represent.
  • Buffered drains the source, then sends the bytes. Right for a small source already in memory; wrong for anything else, because every message is produced before the first one leaves and the wire sees a content-length. The fixture records that it was buffered, so it says plainly that the recorded run was not the streaming run.
  • Spooled lets the body stream and captures the bytes as the transport reads them, and records how the upload ended — complete, stopped after so many bytes and whole frames, or failed at a message index. Replay then turns the source exactly that far, so a recorded early answer never asks for the messages the live peer did not.

A streaming response is drained before you see it, and that is by design: a fixture is an artifact a test will replay, so the exchange is fully known before anything is written, and the recorder reads the whole response ahead of the caller’s first item. Every frame the peer sent is in the fixture, and replay yields them one at a time as a live stream does; what a fixture does not keep is the timing. When the timing is the thing you are watching, reach for the diagnostic capture instead (ReplayClient::diagnostic_with_client_and_backend), which tees the body rather than draining it — the stream stays a stream, and the pieces it arrived in are written to a timing sidecar.

Bodies compare per logical message rather than byte-for-byte over the whole body, so read sizes and HTTP/2 DATA boundaries play no part. A compressed frame is expanded first, when the call’s coding is registered as a FrameCoding: ReplayConfig::with_grpc_coding(GrpcTestConfig::gzip()). Without it a compressed body is refused rather than compared, because coded bytes are an encoder’s output and not the call’s message.

A secret in a request message goes through the redacted twin. Nothing masks a recorded frame in place — protobuf names no field on the wire, and re-encoding a message would change lengths the frame prefix and the length-delimited field both count — so api.query_redacted(endpoint) is the route: the fixture is a twin built from a redacted clone of the endpoint, and every #[wire(sensitive)] field was a placeholder before it became a message. Replay compares that twin against the twin the next run builds.

It asks the endpoint to be Clone + ApiRedact, which is the recordable shape: hold the values the messages are made from and build the MessageSource in body(), exactly as the retryable shape above does. Two gaps come with that, and both are deliberate:

  • An endpoint that owns a one-shot source is not Clone, so there is no second description to redact — and such a call has no deterministic replay either.
  • A gRPC reply is recorded exactly as the peer sent it. A service that mints a credential writes it into the fixture in the clear.

What the recorder refuses rather than half-doing is an ask it cannot meet: a body-key class over a gRPC body that crossed the wire, and a value registered with ReplayConfig::with_sensitive that appears in any gRPC body, a twin’s included — a registered value inside a twin is one the twin did not redact.

What a fixture cannot reproduce is anything that is a property of a live connection: backpressure and flow-control credit, a RST_STREAM and the code it carried, a deadline expiring mid-upload, the interleaving of an answer with a read. Those belong to a transport suite driving a real peer — grpc_test_capi_rs/tests/wire.rs scripts an HTTP/2 peer for exactly this — and a fixture is not evidence about them.

Where the evidence is

grpc_test_capi_rs is the reference client, and its six suites are what the conformance table in capi_grpc’s README cites row by row: tests/streaming.rs drives Sum against a real tonic peer on every lane and adapter, tests/server_streaming.rs drives Count the same way, tests/wire.rs drives both against a scripted HTTP/2 peer, tests/compression.rs against both under a policy, tests/metadata.rs for metadata and rich status, and tests/recording.rs through the test framework’s recorder. That table also says which build each row was measured in — a configuration that only compiles says so there.

Next: switching outputs — the unifying idea behind stream/deferred/ paged variants of one endpoint.

GraphQL

A GraphQL client library is readable source. Its operations are documented Rust structs — the fields are the operation’s variables — beside the types the response decodes into, and a schema module holds the service’s vocabulary: the scope markers selections are written against, its enums, its input objects. That is the library’s product, and it is the thing a user reads, greps, and jumps into.

capi_graphql provides the runtime those definitions plug into — a small family of generic endpoints (GraphqlCall for the ordinary POST, Get, Persisted, Uploads, Subscribe, GraphqlBatchCall, and GraphqlWsConnect), riding the ordinary Http capability and the stock body decoder — plus the graphql! macro, which is the consumer’s power tool rather than the library’s generator.

impl GraphqlOperation for GetUser {
    type ApiConfig = MyConfig;
    type Scope = schema::Query;
    type Root = get_user::Root;
    type Data = Option<get_user::User>;

    const KIND: OperationType = OperationType::Query;
    const NAME: &'static str = "GetUser";

    fn variable_definitions() -> &'static [VariableDefinition] {
        &[VariableDefinition { name: "id", graphql_type: "ID!" }]
    }

    fn selection() -> Selection<Self::Scope> { … }

    fn extract(root: get_user::Root) -> Self::Data { root.user }
}

let user = api.query(GetUser { id: Id::new("1") }).await?;

What a client crate adds is small. Its config implements GraphqlApiConfig — two methods, graphql_url() (the one absolute URL every operation posts to) and json_lib() — and its root invokes graphql_schema! once:

capi_graphql::graphql_schema!(
    schema = "schema.graphql",         // the SDL, resolved against CARGO_MANIFEST_DIR
    config = GraphqlZeroConfig,
    schema_module = declared,          // the crate hand-writes `pub mod schema`; `emitted` has the macro write it
);

scalars(DateTime = crate::scalars::DateTime) maps custom scalars (unmapped ones fall back to String), and upload = Upload names the SDL scalar whose variables carry files. That one invocation embeds the schema and exports the crate’s own graphql!, which a consumer invokes from outside the crate to author an operation the library never shipped, validated against the same schema at compile time:

graphqlzero_capi_rs::graphql! {
    query AlbumTitle($id: ID!) {
        album(id: $id) { title user { username } }
    }
}

Whether a GraphQL service authenticates is a property of the deployment, not of an operation, so every runtime endpoint declares Auth::DEFAULT.optional(): a config that holds credentials installs a store and every call carries them, while an open service sends bare, with no store at all. Every request sends Accept: application/graphql-response+json, application/json.

The pieces, briefly:

  • An operation value is its variables. The struct’s wire encoding is the request’s variables object, and the struct converts into GraphqlCall through IntoEndpoint, so query() takes it directly. Nullable variables and input-object fields are Tristate<T>, keeping GraphQL’s absent/null/value distinction; a schema’s enums and input objects reach a document through ArgumentLiteral.
  • Decode types are named for the schema. They sit in a module named after the operation, where Root is the decoded data object and every other type carries the name of the schema type it projects — get_user::User, get_user::Company — so the Rust reads like the document. Where one operation projects the same type twice, every projection of it takes the selection path that reached it instead. Libraries write this by hand; graphql! emits it.
  • The response envelope is decoded once, centrally: { data, errors } splits into the operation’s typed output or a GraphqlErrorResponse that preserves any partial data alongside the spec’s error objects (message, locations, path, extensions). That envelope is every operation’s Endpoint::ErrorGraphqlErrorResponse<Op::Data>, so a library writes no domain error enum — and a caller recovers it typed with err.api_error::<GraphqlErrorResponse<Option<get_user::User>>>(), then reads .errors() and .data(). A non-success status without an envelope is UnexpectedStatus and a 2xx that isn’t one is Decoding: the standard ladder, with no per-library decoder code.
  • graphql! is the downstream feature. graphql_schema! reads the SDL once per crate and exports the crate’s graphql! wrapper with the schema embedded, so a consumer writes a selection the library never shipped — validated against that schema at compile time, with no code-generation step anywhere. Under schema_module = declared the macro emits no vocabulary of its own and resolves against the library’s hand-written schema module.
  • Schema drift is checked at test time, not compiled against. A library pins an API vintage deliberately, so conformance belongs beside the fixtures: projection::<Op>() reads back the requirements an operation’s code places on the schema — its selection, its variable declarations, the wire shapes of its types — and check_operations verifies the committed SDL still satisfies them, reporting in the schema’s own language. The checker is the conformance feature, a host-only test surface (the schema IR lives in capi_graphql_schema) that a client enables in its dev-dependencies and runs from a test. This is the consumer-driven-contract idea in GraphQL clothing: project what the code needs, check it against what the provider publishes.
  • Render equality holds the source to its intent. Each operation is declared a second time through the crate’s own graphql!, and operation_document proves the two send byte-identical documents — so a selection, alias, or argument that drifts from what was meant fails a test.
  • Queries can ride GETapi.query(op.via_get()) moves the document and variables into the percent-encoded query string and drops the body, so CDNs and browsers can cache the response. The QueryOperation marker is what keeps mutations off the GET transport at compile time.
  • Persisted queries (apq feature) — api.persisted().query(op) sends a SHA-256 of the document instead of the document, registering it exactly once on a cache miss: smaller requests on every call after the first, with the register-on-miss dance handled by a runner, above decoding, where the error envelope is already typed.
  • File uploads (uploads feature) — Upload-typed variables travel as multipart file parts per the GraphQL multipart request spec, streamed on native transports; the request JSON rides beside them with null at each file position, and api.query(op) works unchanged.
  • Subscriptionssubscription operations run over GraphQL-over-SSE in distinct connections mode: api.query(op) returns a stream of the operation’s data type, each next event decoding through the same envelope semantics as every other response, until the server completes. One root field per subscription. For graphql-transport-ws servers the ws feature adds a WebSocket session, shaped by lane: on the async lane GraphqlWsSession::handshake then split() yields a caller-spawned GraphqlWsDriver and a GraphqlWsHandle whose subscribe returns per-subscription typed streams demultiplexed by id over one connection; on the blocking lane GraphqlWsSubscription::open runs one subscription per connection with a blocking pump. The config’s GraphqlWsConfig supplies connection_params (the connection_init payload, where a token rides) and a graphql_ws_url that defaults to graphql_url.
  • Fragments and spreads compose selections, in the library’s source and across graphql! invocations alike; the Scope association is what proves a fragment belongs where it is spread. An interface selection without type conditions decodes as a plain struct of the interface’s declared fields; with conditions, common fields land in every variant of a __typename-tagged enum and uncovered concrete types decode into a typed common-fields fallback. Unions take inline fragments only.
  • @batch repeats one root field with per-entry inlined arguments under b0, b1, … aliases and restores entry order on decode — one request answering a whole set of lookups.

The reference client is graphqlzero_capi_rs; the authoring recipe lives in the capi-rs-author skill (references/graphql.md).

Middleware

Middleware is Capi’s client-side request pipeline: a phase-ordered set of members that a config registers once and every request under that config traverses. A member can inspect and rewrite the request on the way down, inspect and rewrite the response on the way up, or answer without ever reaching the wire — all working on headers and metadata, below the decoder. Request IDs, cookies, and redirect following live here.

One traversal

One traversal is one trip. capi_transport’s Traversal drives it end to end, in this order:

OrderStepWho runs hereKind
1Phase::Earlymembers that must see the request first (request identifiers, trace stamps)member
2Phase::Userthe config’s caller-supplied members, and where a per-request delta mergesmember
3Phase::Midmembers after the caller’s, before the near-wire onesmember
4Phase::Latenear-wire mutation: a cookie jar reads heremember
5Gatethe rate-limit wait; the lane sleepsplane
6Sealcredential resolution — renewing a seated credential — placement, then the resolved signerplane
7Transmithop-by-hop validation, the Host strip, the capability send, transfer stagestraversal
8Unwindresponse halves, in reverse ordermember
(decode)below the pipelinedecoder layer

A query is one or more traversals; a runner decides how many.

Three things about that order are load-bearing:

  • The gate sits after mutation and before the seal. The rate-limit wait happens before signing, so a signature’s timestamps are computed after the wait rather than staled by it — and the seal resolves the credential at the moment of use, renewing a seated one that the wait left near expiry.
  • Nothing mutates after the seal. There is no step between the seal and the wire for a member to occupy, so what the signer signed is what crosses the socket. Transmit’s Host strip and hop-by-hop validation are the traversal’s own, and they run per attempt.
  • Every exchange goes through one traversal. The transmit invariants live inside it, so no re-entry path — a runner’s re-dispatch, a decoder’s follow-up, a credential mint — can skip or re-implement them.

Within a phase, insertion order is execution order: “before X in phase P” is expressed by inserting earlier in P, and crossing a boundary by choosing the phase.

The three traits

A member implements whichever halves it needs. All three are plain synchronous methods in every lane — there is no async variant — and each call must be non-blocking and bounded, because the traversal owns the loop and the clock.

pub trait RequestMiddleware: MaybeSend + MaybeSync + 'static {
    fn on_request(&self, ctx: &ApiContext, head: &mut RequestHead, body: &mut Body)
        -> Result<RequestFlow, MwError>;

    /// Whether this member serves `lane`. Defaults to `true` everywhere.
    fn serves_lane(&self, lane: LaneId) -> bool { true }
}

pub trait ResponseMiddleware: MaybeSend + MaybeSync + 'static {
    fn on_response(&self, ctx: &ApiContext, head: &mut ResponseHead, body: &mut Body)
        -> Result<ResponseFlow, MwError>;

    fn serves_lane(&self, lane: LaneId) -> bool { true }
}

pub trait ExchangeMiddleware: MaybeSend + MaybeSync + 'static {
    /// What the request half hands its own response half.
    type State: MaybeSend + 'static;

    fn on_request(&self, ctx: &ApiContext, head: &mut RequestHead, body: &mut Body)
        -> Result<(RequestFlow, Self::State), MwError>;
    fn on_response(&self, state: Self::State, ctx: &ApiContext,
                   head: &mut ResponseHead, body: &mut Body)
        -> Result<ResponseFlow, MwError>;

    fn serves_lane(&self, lane: LaneId) -> bool { true }
}

ExchangeMiddleware is the paired form: its request half hands its own response half a typed State, so the two are matched by the compiler rather than by a lookup. The two shipped exchange members show both uses of that State: CookieMiddleware carries the request’s URL and the instant it was sent (CookieExchange), so its response half files Set-Cookie against the right origin, and FollowRedirects carries a replay handle (RedirectExchange) for the hop it may re-send.

The verbs

A request half returns a RequestFlow:

pub enum RequestFlow {
    /// Proceed to the next member.
    Continue,
    /// This response is the traversal's result. The wire is never reached.
    Respond(Box<Response>),          // built with RequestFlow::respond(response)
}

A response half returns a ResponseFlow:

pub enum ResponseFlow {
    /// Hand the response to the next member on the way out.
    Continue,
    /// Send this request: another exchange, gated, sealed and transmitted like
    /// any other.
    Resend(Box<Request>),            // built with ResponseFlow::resend(request)
}

The exceptional payloads are boxed so the common Continue verdict stays pointer-sized at the call boundary; the respond and resend constructors do the boxing. An Err from either half aborts the traversal and travels outward, unchanged, as the query’s error; the traversal’s event record names the member it stopped at.

A Resend resumes the request walk at the re-sending member’s own seat: the request it handed back already carries what the members before it applied, so they do not run again, while that member and the ones after it do. Their pending response halves are the new ones; the halves of earlier seats stay untouched and run once, on the response that ends the chain. The seat is therefore what a member pairs with — per wire exchange from the re-sending seat onward, once per operation before it — and the request’s plan carries the resend_budget that bounds the chain. A member takes the next exchange’s body from Body::replay_handle() on the way down.

Iteration that re-describes the exchange — retry with backoff, payment, reactive credential renewal — is runner work. A runner holds the endpoint and re-dispatches by endpoint value, so each attempt re-derives its description from the endpoint rather than replaying a request someone else built. Redirect following has one of each: capi_extras::middleware:: FollowRedirects for the config-armed seat (and for an endpoint that cannot be described twice), api.follow_redirects(n) for the caller-installed one.

The body view

Body is the body as the member can see it — one type serves both directions. The surface is the body’s identity and its dressing: swap the stream (replace, take), wrap it in a codec (with_codec), register an observation (observe), take a replay handle on it for an exchange that may be sent again (replay_handle), and read what it declares about itself (body_source, content_length, content_coding, is_empty). There is no read method: pulling bytes is flow-time, and flow-time belongs to the byte plane, which runs in the stream’s own lane once the traversal has handed the request to the wire.

A decision that genuinely needs body content belongs at the description altitude (the endpoint, the codec), where the content is still typed; a decision that needs to watch bytes move belongs to the transfer plane.

Registering members

A config names its pipeline once:

fn middleware(&self) -> Pipeline {
    self.inner
        .middleware()
        .with_request(Phase::Early, InjectRequestId::new("x-request-id"))
}

A decorator forwards self.inner.middleware() and seats its own members on top, so the wrapped config’s members ride too. Pipeline clones by reference count, so a config with a large layout can hold one and return a clone.

The three seating verbs come in a push_* form (on &mut self) and a consuming with_* form: push_request / with_request, push_response / with_response, and push_exchange / with_exchange.

Per request, a caller attaches a delta that merges at Phase::User:

api.scoped()
    .with_middleware(my_request_member)          // or with_response_middleware,
    .query(endpoint).await?;                     // or with_exchange_middleware

The delta is a MiddlewareOverride (in the facade prelude): with_request / with_response / with_exchange seat a member at Phase::User, and the with_request_in(phase, ..) / with_response_in / with_exchange_in forms name the phase. It rides the whole operation’s lineage: a paged iterator’s later pages, a resumable stream’s reconnects, and a decoder’s follow-ups all apply it freshly at their own assembly. A transfer stage that must be shared across requests rather than built per request — one Throttle as a process-wide cap — is seated with with_shared_transfer_stage(Arc<dyn TransferStage>).

The other two entries

Every ordinary exchange takes the traversal above. Two entries take a shortened one, and both are about not doing something the ordinary walk does:

  • A credential mint (Traversal::credential_exchange) runs the phases and the transmit invariants exactly as any exchange, but the seal places whatever the store holds without renewing it — a mint must not depend on the credential it exists to produce — and the gate is skipped: a config’s limiter models the API service, not the identity provider, and queueing a mint behind the requests waiting for it can deadlock freshness.
  • A capture (Traversal::capture) is the shape of an exchange without the exchange: everything a send does short of the wire — the phases, the seal, the transmit invariants — and no response, so no unwind. It is how a request is finalized for a batch envelope or a tunneled frame (the capability plane).

Lanes

One pipeline serves every transport lane the config’s endpoints use — plain HTTP, WebSocket upgrades, gRPC. A member that is genuinely wrong on some lane says so:

fn serves_lane(&self, lane: LaneId) -> bool {
    lane == LaneId::of::<Http>()
}

The traversal asks every member this before any of them runs, and refuses an off-lane one by name, phase, position, and lane. The boundary is behavioral, not cosmetic: a trigger that simply never fires on some lane is honest inertness and needs no restriction, while a member that would faithfully measure the wrong thing there — progress-metering a WebSocket upgrade’s empty handshake instead of the session it was attached for — declares its lanes and is refused elsewhere.

Watching a traversal

The traversal is the only thing that can attribute time to one member, so per-member observation is its job rather than an instrumentation member’s. A traversal emits capi_transport::traversal::TraversalEvents as it goes — Started, MemberEntered and MemberExited, Gated, Sealed, Transmitted, ResponseReceived, ShortCircuited, Resent, Aborted — and closes with Completed(TraversalInfo), the record: which members ran, on which half, how long the gate waited, how long the seal took, how many transmit attempts, and the outcome. Subscribe on the config’s base context and every traversal — including a decoder’s follow-ups and a runner’s re-dispatches — bubbles up. A context with no notification channel builds no record and allocates nothing.

The plane below: transfer stages

There is one thing no member can do, however it is written: watch a byte move. A member computes on a request and a response as values, at plan time, and the bytes flow afterwards. Bandwidth caps, progress reporting, byte accounting, stall detection and wire capture all want that later moment, and it is a different plane.

A transfer stage runs there:

pub trait TransferStage: Debug + MaybeSend + MaybeSync + 'static {
    fn begin(&self, transfer: &TransferDescriptor) {}
    fn pace(&self, transfer: &TransferDescriptor) -> Result<(), Duration> { Ok(()) }
    fn on_chunk(&self, transfer: &TransferDescriptor, len: usize) -> Result<(), TransferAbort> { Ok(()) }
    fn on_bytes(&self, transfer: &TransferDescriptor, bytes: &[u8]) -> Result<(), TransferAbort> { Ok(()) }
    fn complete(&self, transfer: &TransferDescriptor) {}
}

Every callback takes the same one argument, and that is deliberate: the descriptor is where the plane grows. A capability stages turn out to need becomes a field and an accessor there, rather than a breaking change to five signatures. The framework mints a fresh one for each callback it fires, so it is a snapshot of one moment — which is what lets it carry a timestamp without carrying a clock.

Stages attach per request, and the traversal attaches them at Transmit — below every member and below the seal, where nothing shapes the request any more. Three things follow from that placement:

  • The body they meter is the one that went on the wire, on the attempt that actually went out. A re-dispatch runs a fresh traversal, so each attempt reports itself.
  • The bytes they see are the coded ones. A codec sits between the source and outer planes with opposite orientation per direction, so the framework picks the outer plane for a request and the source plane for a response; a stage is never told a codec is there.
  • Nothing replaces the body, so declared content lengths behave exactly as they would with no stage attached.
api.throttle(Throttle::download(1_500_000).with_burst(64 * 1024))
    .query(endpoint).await?;

// the sugars chain, so one request can carry both stages
api.throttle(Throttle::download(1_500_000))
    .progress(Progress::download(sender, |update| println!("{update}")))
    .query(endpoint).await?;

// and `with_transfer_stage` is the general slot every stage rides,
// shipped or third-party
api.scoped().with_transfer_stage(my_stage).query(endpoint).await?;

capi_extras::stages ships two: Throttle, a bytes-per-second cap (curl’s --limit-rate), and Progress, which emits ProgressNotifications a listener runs off the read path. Each has an extension trait carrying its verb (ThrottleExt, ProgressExt); a third party ships its own the same way.

What a stage can reach, and why

A stage runs at the most sensitive position in the traversal — after every credential has been placed. What keeps that safe is not where it sits but what the trait hands it: byte counts, read-only slices, and a TransferDescriptor carrying the direction, the wire-plane length, the context id, and now() — when the framework observed this event, taken once per fire and shared, so every stage watching a chunk agrees about when it arrived. No URL, no headers, no method, and no way to reach the body value. Not the ApiContext either: its extension map is removable, its notification channel is writable, and its RNG is a seeded stream a replay depends on. Not even the clock — only the reading, so sleep is unreachable by construction. A stage cannot produce bytes either, so a signature computed before it runs covers exactly what was transmitted. Asking for content at all means overriding on_bytes, which a reviewer can see in the stage’s source.

Pacing is a verdict, not a sleep: pace returns the wait and the framework observes it. One stage definition therefore compiles sync and async, and no stage ever blocks a thread or holds an executor.

The lane bound is a sealed TransferCapable marker on Http and Grpc. A session lane’s traversal only ever sees the empty upgrade handshake, never the session traffic, so attaching a stage there is a compile error rather than a stage faithfully metering the wrong transfer.

Member, stage, or codec

Codecs transform, stages observe and pace, members decide. Changing bytes is permanently outside the stage plane: a stage runs after signing, and the framework already owns a transformation plane — the codec system, at construction time, under the author’s control, before signing. A stage that wants to change bytes is a codec in the wrong seam. A member that wants a different description of the exchange — a fresh body, another endpoint — is a runner; one that wants the exchange it already holds sent again returns ResponseFlow::Resend.

Cookies

Cookie handling is a pipeline member. capi_cookies provides Cookie, CookieJar, CookieMiddleware, CookieConfigExt (the config onion layer), and HasCookieJar (whose cookie_jar() a form flow reads). CookieMiddleware is an ExchangeMiddleware seated at Phase::Late: its request half applies stored cookies to the final URL, and — because response halves unwind in reverse — its response half is the first to see Set-Cookie. It is a no-op on wasm, where the browser owns the jar. Add the CookieConfigExt layer to your onion and the jar rides every request automatically.

When the cookie is the credential, CookieConfigExt::session_auth(name) also installs a CookieSessionSource — an auth store that reports the named cookie’s presence to the seal and places nothing, leaving the member to write the header — so endpoints behind the session declare Auth::DEFAULT like any other.

A redirect hop re-runs the members seated after the one that took it, so the jar supplies each hop’s own origin its cookies — the runner’s hops, which are whole traversals, get the same treatment for the same reason.

The lightweight alternative

For a one-off header on a single call, you don’t need a member at all — use a scoped request:

pub fn with_conversation_id(&self, id: impl Into<HeaderValue>) -> ScopedRequest<'_, GrokConfig, Client> {
    self.scoped().with_header("x-grok-conv-id", id)
}
// api.with_conversation_id("conv_42").query(endpoint).await?

scoped().with_header(...) attaches per-call headers without touching the config’s pipeline — the right tool when the change is per-request, not client-wide.

Next: rate limiting, the plane the traversal’s gate consults.

Rate Limiting

Rate limiting is a first-class, pluggable concern: a small trait in capi_rate_limit and a set of implementations in capi_limiters. You can pace requests proactively (a local budget), reactively (obey the server’s X-RateLimit-* / Retry-After), or both — and swap the algorithm without touching endpoints. The same trait meters the transfer plane, so a byte throttle is a rate limiter denominated in bytes.

The trait

pub trait RateLimiter: Debug + MaybeSend + MaybeSync + 'static {
    fn try_acquire(&self, now: Instant) -> Result<(), Duration>;                    // required
    fn try_acquire_n(&self, now: Instant, n: u64) -> Result<(), Duration> { … }     // charge n units; default: one operation
    fn peek_delay(&self, now: Instant) -> PeekDelay { PeekDelay::Unsupported }      // the wait, without consuming a slot
    fn peek_delay_n(&self, now: Instant, n: u64) -> PeekDelay { … }
    fn unit(&self) -> LimiterUnit { LimiterUnit::Operations }                       // what the budget is counted in
    fn update_from_headers(&self, clock: &ApiClock, headers: &HeaderMap)
        -> Result<(), HeaderUpdateError> { Ok(()) }                                // learn from the server
}

try_acquire is the heart and the only required method: Ok(()) to proceed, Err(delay) to wait that long first. try_acquire_n charges n units of whatever the limiter is denominated in — the default charges one operation whatever n says, which is right for a requests-per-minute limiter, where a request costs one request however many bytes it carries. unit() declares the denomination: Operations (the default), Bytes, Tokens (model tokens, or any quota a service meters a request’s content by), Other(&'static str), or Unspecified for the no-op limiters and for a composite whose members disagree. WaitableRateLimiter adds wait_until_ready(clock).

Limiters are carried as a RateLimitNone, Static(&'static dyn RateLimiter), or Arc(Arc<dyn RateLimiter>) — and installed through the sealed ToRateLimiter trait, which is implemented for &'static T, Arc<T>, and RateLimit itself. NO_RATE_LIMIT is the empty carrier, and ApiConfig::default_rate_limiter returns it unless a config says otherwise.

Proactive: GCRA

The Generic Cell Rate Algorithm paces requests against a local budget — smooth, allocation-free, and no_std-friendly (it is arithmetic on a theoretical arrival time). GcraLimiter is the implementation, and Rate expresses the budget:

let limiter = GcraLimiter::per_second(2, 3);   // 2 req/s sustained, burst of 3
let rate = Rate::per_second(10);               // or per_minute / per_hour / per_day / unlimited

Don’t conflate the two per_seconds: GcraLimiter::per_second(rate, burst) takes two arguments (sustained rate + burst); Rate::per_second(tokens) takes one.

Reactive: header-based

When the server publishes its limits, obey them rather than guessing. HeaderLimiter reads quota headers (X-RateLimit-Remaining / -Reset) and Retry-After off each response through update_from_headers, so your pacing tracks the server’s actual state. It is built from a description of the headers the service sends — HeaderLimiter::from_headers(..) with a HeaderPolicy naming the remaining/reset headers and how the reset is spelled (ResetAfter::Seconds, Timestamp, HttpDate, SecondsOrHttpDate, Duration), or HeaderLimiter::retry_after(RetryAfter::Seconds | Auto | ..) for a service that only ever says Retry-After. Combine a GcraLimiter (a proactive floor) with a HeaderLimiter (reactive correction) for the best of both.

Who feeds the reactive limiter is the traversal: on the final attempt’s response, before any pipeline member can transform it, the traversal hands the head to the request’s limiter — best-effort, so a limiter that cannot read the headers keeps the parameters it has. Only the primary traversal entry does this; a credential mint skips the gate, and a capture has no response.

Cost-aware limiters

Not every quota counts requests. A byte-rate cap counts bytes; a model API’s tokens-per-minute counts tokens. MeteredLimiter is a GCRA budget spent in a declared unit — MeteredLimiter::bytes_per_second(bytes, burst_bytes), or MeteredLimiter::new(gcra, LimiterUnit::Tokens) — whose try_acquire_n charges the amount it is handed and admits regardless of it, so a single operation larger than the burst waits rather than being refused forever.

Composition follows from the denomination. Pair a requests-per-minute limiter with a tokens-per-minute one, call try_acquire_n with a token count, and each member reads n its own way: the operations limiter charges one, the token limiter charges n. The one composition that cannot work is two members denominated in different metered quantities, since only one n crosses the call — the composite’s unit() reports Unspecified for exactly that case.

The transfer plane is where a bytes-denominated limiter is driven. Throttle::with_limiter(impl ToRateLimiter) from capi_extras is a transfer stage that charges each chunk’s length against the limiter — the only place try_acquire_n is called with n > 1 today — and one Throttle seated as a shared stage becomes a process-wide bandwidth cap (Middleware). A throttle handed a limiter denominated in operations paces per chunk rather than per byte, which is logged when it happens and not refused.

Header-driven limiters

Build a header limiter from the contract published by the API being called:

use capi_rs::rate_limiters::{HeaderLimiter, ResetAfter, RetryAfter, RETRY_AFTER};

let quota = HeaderLimiter::from_headers(
    "x-quota-remaining",
    ResetAfter::Timestamp("x-quota-reset"),
    "x-quota-limit",
);
let retry = HeaderLimiter::retry_after(RetryAfter::Seconds);

from_headers accepts the quota, reset, and capacity header names, while retry_after selects how to parse the standard Retry-After header. The generic RETRY_AFTER static accepts both standard forms: delay-seconds and HTTP-date.

A static limiter costs nothing to reference — and it is process-global, one admission history for every client in the process that names it. A service whose quota is partitioned by account, resource, or endpoint wants one HeaderLimiter per partition instead, held in an Arc.

Wiring it up

Where the gate sits decides what a wait can and cannot affect. The traversal runs the rate-limit gate as its fifth step — after the Late phase, before the seal — so every member has shaped the request before the lane sleeps, and every signature is computed after the wait rather than staled by it (Middleware has the full order).

A client-wide default comes from the config. BaseConfig::with_default_rate_limit(..) installs it, and ApiConfig::default_rate_limiter() reports it; a wrapping config forwards the inner one’s:

let config = BaseConfig::new(base_url).with_default_rate_limit(&RETRY_AFTER);

fn default_rate_limiter(&self) -> impl ToRateLimiter {
    self.inner.default_rate_limiter()
}

An individual endpoint overrides rate_limit(&self, config, context) — which by default delegates to config.default_rate_limiter() — when one operation has a tighter budget than the rest of the API. Return a shared handle from it, a &'static limiter or an Arc-held one resolved from the config, never a limiter built fresh in the method: the hook is re-resolved once per send, for the primary request and again for each follow-up a decoder issues (a next page, a reconnect), and only a shared instance carries its admission history across those sends.

A single call adjusts its own limiter through the scoped request — api.scoped().with_rate_limit(limiter) to substitute one, .no_rate_limit() to skip the gate for this send.

When more than one limiter applies, composition is max-delay-wins: a tuple of up to four limiters, or a Vec<RateLimit>, acquires only when every member allows, waiting the longest delay any of them asks. Every member is visited on each attempt and the order of the tuple does not matter — which is unlike the pipeline’s members, which run in phase order, and unlike runners, which nest.

Next: runners, the layer above the decoder that can drive a query more than once.

Runners

A runner drives a query from a configured request to its final decoded output. It’s the layer above the decoder — where a pipeline member sees one request and one response, a runner sees the typed Output and decides whether to send again — so it’s what expresses “poll until done”, “retry on a transient body”, or “follow this redirect” without changing the call site. Users still just write api.query(endpoint).await; the runner is invisible until you install one.

Why runners exist

A pipeline member shapes one request and one response — the right place for request IDs and cookies. A response member can send the exchange it holds again (ResponseFlow::Resend, which is how FollowRedirects follows a hop), but it only ever re-sends that exchange, resuming at its own seat. A runner holds the endpoint and re-derives the next attempt’s whole description from it — a new URL, a fresh body, a renewed credential. Re-send the same exchange: a member. Re-describe it from the endpoint: a runner. That is why anything that re-describes is runner work:

  • Polling — query, check whether status == "done", re-query if not.
  • Body-aware retry — a 200 OK that says { "retryable": true }; a member can’t see that. WaitUntilRunner’s predicate reads the decoded output; RetryRunner’s ranges over the QueryError an attempt produced.
  • Redirect following, Retry-After compliance, and reactive credential renewal on a 401 — each decides from the response and re-dispatches by endpoint value.

Runners sit above the decoder and can run the whole prepare→send→decode cycle zero or more times. Each pass is one full traversal:

[query call] → [runner] → [traversal] → [client] → [unwind] → [decoder] → [runner] → [result]
                  └──────── one or more round trips, possibly looping ────────┘

Because every attempt re-derives its request from the endpoint, an endpoint that can be described twice is exactly an endpoint that is Clone — and that bound is the whole re-dispatch contract. An endpoint that cannot be described twice simply is not Clone, and the refusal is the compile error at .query(..).

The default: DirectRunner

Without a custom runner, queries go through DirectRunner — exactly one traversal, which may itself carry a member’s Resend hops, returning the decoder’s output. It has no extra bounds on the endpoint (no Clone, no decoder-shape restriction), so it works for body, stream, paged, and WebSocket endpoints alike. For the 99% case the runner system is invisible and free.

The trait

// blocking lane
pub trait Runner<Endpt: Endpoint> {
    type Output;
    fn run<Client>(
        &mut self,
        access: &mut RunnerAccess<'_, Endpt::ApiConfig, Client, Endpt::Requires>,
        endpoint: Endpt,
    ) -> Result<Self::Output, QueryError>
    where
        Endpt::Requires: Capability,
        Client: Supports<Endpt::Requires>;
}

// async lane, and every browser-wasm build: a boxed future, because `run`
// borrows both `self` and `access`
fn run<'r, 'a, Client>(
    &'r mut self,
    access: &'r mut RunnerAccess<'a, Endpt::ApiConfig, Client, Endpt::Requires>,
    endpoint: Endpt,
) -> RunnerFuture<'r, Self::Output>
where
    Endpt: MaybeSend + 'r,
    Self::Output: MaybeSend,
    Endpt::Requires: Capability,
    Client: Supports<Endpt::Requires> + 'r,
    'a: 'r;
  • Parameterized by Endpt. Each impl declares its own bounds — a polling or retrying runner adds Endpt: Clone, because re-dispatch needs a fresh endpoint — while the trait stays unconstrained. Keep constraints on the impl, not the trait.
  • Output reshapes freely. Transparent runners set type Output = Endpt::Output; a reshaping chain returns something else (e.g. (ResponseHead, Endpt::Output) from a head capture).
  • RunnerAccess is the handle you dispatch through, and it has exactly two ways to reach the network: exchange(endpoint) performs one round trip and yields the undecoded ApiResponse, and execute(endpoint) is the fused convenience — the same exchange followed by the endpoint’s decode. Loop either, call once, or short-circuit and never call. The handle can only be obtained inside Runner::run, so all dispatch flows through the runner system.

Composition

Runners compose by tuple through RunnerStack, layering left-to-right with the first element outermost — the same convention as rate limiters:

api.scoped()
   .with_runner((WaitUntilRunner::new(spec, pred), RetryRunner::with_predicate(spec, decide)))  // wait wraps retry
   .query(get_video).await?

with_runner composes your stack onto DirectRunner at the bottom. The stack is endpoint-agnostic — whether it actually implements Runner<Endpt> is checked at the .query() call, so you build stacks freely and the type system validates on use. Chained with_runner calls nest the existing runner inside the new one, so the runner installed last is outermost; only within one tuple is the first element outermost.

A chain has two kinds of member. Wrappers (retry, polling) re-dispatch through whatever sits beneath them and stack freely with with_runner. A dispatcher performs the exchange itself, so a chain holds exactly one, innermost; it seats with with_dispatcher, sinking beneath the wrappers already installed, and a second dispatcher is a compile error rather than a silent replacement. DirectRunner is the default dispatcher; implementing your own is for strategies that need the exchange in hand — a cache answering without the network, a bespoke send discipline.

The shipped runners

capi_extras::runners ships five, each with the extension trait that installs it (all in the prelude):

RunnerSugarKindWhat it does
RetryRunner / RetrySpecapi.retry(spec)wrapperre-dispatches on a retryable QueryError, with backoff; with_predicate(spec, decide) takes a Fn(&QueryError) -> bool
WaitUntilRunner / WaitSpecapi.wait_until(spec, pred)wrapperpolls until the predicate over the decoded output holds (below)
RetryAfterRunner / RetryAfterSpecapi.retry_after(spec)dispatchercomplies with a server-directed Retry-After
RedirectRunnerapi.follow_redirects(n)dispatcherfollows a Location by re-describing the endpoint as a RedirectHop<Endpt>, decoded through HopDecoder
RefreshRunnerapi.refresh_on_unauthorized()dispatcherrenews the query’s credential through the store’s seat on a 401 and dispatches once more

The three dispatchers act on the undecoded response, so they perform the exchange themselves and seat with with_dispatcher; wrappers stack above them. All of them re-dispatch by endpoint value, deriving a fresh request from the description each time. A chain holds one dispatcher, so retry_after and a raw-response terminal (with_raw_response()) cannot co-seat. capim_x402’s payment runner (api.pay_when_required(..) from its X402Ext) is the worked third-party runner on this plane: it reads a 402 and re-dispatches with the payment attached.

The response side: observers and interpreters

What happens after the exchange composes on its own axis, inside the shipped Respond dispatcher. Interpreters consume the response — one per chain, innermost: the default Decode runs the endpoint’s declared decoder, Wrapped preserves the wire wrapper, Raw hands back the ApiResponse itself. Observers wrap whichever interpreter is seated: the head capture pairs the ResponseHead with the inner output, and observers stack. The sugar mounts them — wrapped() / with_raw_response() seat a terminal, with_headers() mounts the observer — and every order lands on the same chain:

// (ResponseHead, Wrapper), retried: the retry drives the head-capturing,
// wrapper-preserving exchange. Any mounting order builds this chain.
let (head, wrapper) = api
    .retry(RetrySpec::new())
    .with_headers()
    .wrapped()
    .query(ep)
    .await?;

Seating a second terminal is a compile error — wrapped() after with_raw_response() has no decode leaf left to replace. A third-party observer or terminal is ordinary code: implement Interpret plus Observer (stacking) or Interpreter (seating) from capi_transport::interpret, and the mounts, the commuting orders, and the wrapper forwarding all apply to it unchanged.

WaitUntilRunner: polling built in

The most common pattern ships in capi_extras::runners. It pairs perfectly with a deferred endpoint:

use capi_rs::extras::{WaitSpec, WaitUntilExt};

let response = api
    .wait_until(
        WaitSpec::new().interval(Duration::from_secs(2)).timeout(Duration::from_secs(60)),
        |r: &DeferredChatResponse| matches!(r, DeferredChatResponse::Success(_)),
    )
    .query(deferred_id)
    .await?;

WaitSpec tunes the loop — interval, timeout, eager (call once immediately), retry_errors, backoff_multiplier, max_interval, jitter_ratio. Crucially, the pacing runs off the context’s clock and RNG (ApiContext::clock() / rng()), so a polling loop is fully deterministic under replay tests — mock time advances instantly, jitter is reproducible. When the loop runs out of time it fails with a WaitTimeout, whose cause() carries the last attempt’s error when there was one. RetryRunner / RetrySpec are the retry counterpart; RetrySpec::attempt_timeout(d) bounds each individual attempt, always under the whole-call deadline a scoped request’s with_timeout fixes as one instant when the send starts — WaitUntilRunner tightens its own timeout under that ceiling too, and RunnerAccess arms the deadline once so every attempt measures against the same instant (bounding a call).

The predicate ranges over the chain’s output, whatever that is: pair it with with_headers() to test &(ResponseHead, T), or with a HEAD probe to poll until a resource exists:

let head = api
    .wait_until(WaitSpec::new(), |head: &ResponseHead| head.status.is_success())
    .head(&endpoint)
    .await?;   // polls HEAD until it stops 404ing

Writing a custom runner

Wrap an Inner: Runner<Endpt> (default it to DirectRunner) and delegate inside run, adding whatever per-impl bounds you need. The impl is written once per lane; the async twin returns RunnerFuture from a Box::pin(async move { … }):

impl<Endpt, Inner> Runner<Endpt> for LogRunner<Inner>
where
    Endpt: Endpoint,
    Endpt::ApiConfig: ApiConfig,
    Endpt::Requires: Capability,
    Inner: Runner<Endpt>,
{
    type Output = Inner::Output;

    #[cfg(not(any(feature = "async", all(target_arch = "wasm32", target_os = "unknown"))))]
    fn run<Client>(&mut self, access: &mut RunnerAccess<'_, Endpt::ApiConfig, Client, Endpt::Requires>, endpoint: Endpt)
        -> Result<Self::Output, QueryError>
    where Client: Supports<Endpt::Requires>
    {
        log::info!("dispatching");
        self.inner.run(access, endpoint)
    }

    #[cfg(any(feature = "async", all(target_arch = "wasm32", target_os = "unknown")))]
    fn run<'r, 'a, Client>(&'r mut self, access: &'r mut RunnerAccess<'a, Endpt::ApiConfig, Client, Endpt::Requires>, endpoint: Endpt)
        -> RunnerFuture<'r, Self::Output>
    where Endpt: MaybeSend + 'r, Self::Output: MaybeSend, Client: Supports<Endpt::Requires> + 'r, 'a: 'r
    {
        Box::pin(async move {
            log::info!("dispatching");
            self.inner.run(access, endpoint).await
        })
    }
}

Three more impls make a wrapper a first-class chain member: RunnerStack, so with_runner can install it over an existing chain, and ObserverMount / InterpreterMount, so with_headers() and wrapped() thread through it to the interpreter beneath — each a short forward into Inner, as RetryRunner’s are. Use access.overrides_mut() to change per-request overrides between iterations, and access.base_context() for the clock/RNG. Custom runners are an advanced escape hatch — while authoring a normal client you’ll almost never need one; the shipped runners cover the common cases.

Runners vs. pipeline members

Pipeline memberRunner
Layerone request, one responsedecoded output
Seesheaders, status, the plan-time body viewtyped body, stream handle, page iterator
Short-circuit?yes (RequestFlow::Respond)yes (return early)
Send again?the same exchange, from its seat (Resend)a re-described one, by endpoint value
Compositionphase, then insertion orderlater with_runner outermost; within a tuple, first element outermost

They’re complementary: members stamp and read every trip, while a runner decides how many trips there are.

Next: HTML forms, a decoder that itself issues a second request.

HTML Forms

Some services expose no API at all — only pages with forms on them. capi_html_forms makes such a site an endpoint: one query() fetches the page, finds the form, fills it, submits it, and decodes what comes back. The lifecycle is two round trips inside one decoder, and everything about it rides the framework’s ordinary machinery — the submission is a follow-up through the decoder’s door, so the config’s pipeline, the seal, the cookie jar, and the request budget all apply to it.

The six steps

  1. Fetch the page — the endpoint’s own GET, an ordinary exchange whose response must be a success: the decoder buffers the HTML and turns any other status into unexpected_status_with_headers, Location included. (A page that redirects is api.follow_redirects(n).query(flow)’s job; see below.)
  2. Extract the form by the endpoint’s form_selector()FormSelector::id("edit-profile"), ::name("login"), or ::css("form.login").
  3. Apply the updates the endpoint’s form_update() returns to the form’s fields; a field the form does not have is a FormError.
  4. Resolve the action the way a browser would: a relative action against page_url(), an empty or missing one to the page itself, an absolute one as-is.
  5. Submit — a follow-up exchange through the door, built as a SubmissionEndpoint over the flow endpoint.
  6. Decode the submission’s buffered body and head through the endpoint’s decode_submission.

The endpoint

A form-flow endpoint declares type Decoder = FormFlowDecoder and implements FormFlow, which fixes its error type and asks for five things:

#[capi]
#[derive(Debug, Clone, WireModel, ApiEndpoint)]
#[endpoint(path_template = "/clients/{client_id}/profile")]
pub struct UpdateClientProfile {
    #[endpoint(location = "path")] pub client_id: String,
    pub display_name: String,
    pub notify_on_update: bool,
}

impl Endpoint for UpdateClientProfile {
    type ApiConfig = PortalConfig;
    type Output = ();
    type Error = FormFlowError<PortalError>;      // fixed by FormFlow
    type Decoder = FormFlowDecoder;
    type Requires = Http;

    fn method(&self) -> Method { Method::GET }     // the page fetch
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path(self.path_string())
    }
}

impl FormFlow for UpdateClientProfile {
    type SubmissionError = PortalError;

    fn page_url(&self, config: &Self::ApiConfig) -> impl ToUrl {
        config.base_url().path(self.path_string())  // what relative actions resolve against
    }
    fn form_selector(&self) -> FormSelector {
        FormSelector::id("edit-profile")
    }
    fn form_update(&self) -> impl FormUpdate + MaybeSend + MaybeSync {
        vec![
            ("display_name", FieldUpdate::from(self.display_name.as_str())),
            ("notify", FieldUpdate::from(self.notify_on_update)),
        ]
    }
    fn decode_submission(
        body: Vec<u8>, header: ResponseHead, config: &Self::ApiConfig, _context: ApiContext,
    ) -> Result<Self::Output, ResponseError<Self::SubmissionError>> {
        if header.status.is_success() { Ok(()) } else { Err(build_portal_error(&body, config, header.status)) }
    }
}

The bounds say what the flow needs. The endpoint is Clone, because FormFlowDecoder snapshots it as its decoder State before the page request consumes it and drives the submission from the snapshot. Its Error is FormFlowError<Self::SubmissionError>, so the decoder can report its own failures and your domain error through one type. Its ApiConfig implements HasUrlEncodedLiburl_encoded_lib() -> Codec<UrlForm> — so a URL-encoded form body can be serialized. And its lane is response-shaped (Requires: Capability<Response = Response>), because a submission is a request and a response.

page_url is the page’s own URL: relative actions resolve against it, an empty action submits to it, and the submission’s Referer (always) and Origin (for any method but GET) are derived from it. It usually equals url(), and it should not carry query parameters that have no business on a submission — for a form with an empty action they would ride along.

What the submission sends

The SubmissionEndpoint the decoder builds is an OverrideEndpoint over the flow endpoint, so it keeps the flow’s config and lane and overrides only what the form dictates: the method (the form’s, GET or POST), the url (the resolved action), the query_string — for a GET form the field data set replaces the action’s query entirely, per the HTML spec, including to nothing when the form has no submittable fields — the headers (Referer, and Origin when not GET, each only if not already present), the content_type, and the body. For a POST form the field data is the body and the action’s query is left untouched.

The body follows the form’s enctype. HtmlForm::to_body emits URL-encoded pairs through the config’s codec for application/x-www-form-urlencoded; a Multipart body with repeated parts for multi-select values and file parts for populated file inputs for multipart/form-data; and the HTML text/plain encoding algorithm — one name=value line per entry, CRLF-terminated, no percent-encoding — for text/plain.

Filling a form

FormUpdate is the update source: field_updates(&self) -> Vec<(String, FieldUpdate)>. Implement it on a struct of your own, or use one of the blanket impls — () for no updates, Vec<(K, V)>, [(K, V); N], BTreeMap<K, V>, and HashMap<K, V> for any V: Into<FieldUpdate>. FieldUpdate converts from &str, String, f64, bool, and FileBlob, and from capi_wire_datetime’s Date, DateTime, and Time, which it renders in the formats HTML date, datetime-local, and time inputs accept.

The extracted HtmlForm is a real model of the page’s form: action(), method(), enctype(), fields() (with their metadata, in DOM order), field_names(), has_file_fields(), apply_update(&update), and to_body(..). For a form you will fill often, the crate writes the Rust for you: extract_and_generate_rust_code (and the _by_id / _by_name forms) turns a page into a typed struct with FormUpdate and from_form impls, <label> text as doc comments, and companion enums for the selects — copy-paste-ready source, not a build-time step.

Errors and security

FormFlowError names the stage that failed: Form (extraction, a missing field), UrlResolution, RequestBuild, Transport, or Submission(E) for your own domain error from decode_submission. A QueryError’s api_error::<FormFlowError<E>>() recovers it.

An absolute action is honoured verbatim, and action comes from fetched, possibly attacker-influenced HTML — so a hostile page can direct the submission, with the Referer, the Origin, and everything form_update put into the form, to a host of its choosing. Resolution does not constrain the destination. If submissions must stay within known hosts, pair the endpoint with capi_firewall’s egress allow-list, and let the list cover the resolved action host, not only the page host you fetched from.

Redirects and logins

The flow follows no redirect of its own. A 3xx on the page fetch fails the query, and api.follow_redirects(n) is the runner that re-describes the flow endpoint at the new location and lets the decoder run there. A 302 in answer to the submission arrives whole at decode_submission — body and head, Location included — for the endpoint author to interpret; nothing follows it. A config-armed cookie jar still captures the Set-Cookie on that unfollowed hop, because the submission runs a full traversal and the jar’s response half runs at its phase whether or not anyone follows.

That is also the shape of a login form. A login flow that answers with a redirect belongs inside the client library, as an authenticate(..) method that drives api.follow_redirects(n).query(login_flow) and lets the jar keep the session — the cookie-login scheme in Auth Strategies is the version with no redirect to follow.

Backends

Form parsing is platform-specific, and the framework hides it: off browser wasm the crate links the standard library and parses with scraper/html5ever; on the browser-wasm target it uses DOMParser, through JS bindings that ride the crate’s std feature as its js capability. Your FormFlow code is identical across both — the compilation target picks the backend, as Features and Targets describes.

Next: Clients, Lanes, and the Request Budget — the ApiClient abstraction the form flow (and every endpoint) sends through, and the adapters that implement it.

Clients, Lanes, and the Request Budget

“Write the endpoints, ship everywhere” rests on one abstraction — the client — and on the rule that the compilation target picks the platform while features switch capabilities on. Features and Targets owns that rule; this chapter owns the client side of it: the trait, the shipped adapters, the table that says which adapter can do what, and the request budget an adapter enforces.

The ApiClient abstraction

ApiClient is deliberately tiny — the identity of a client, not its behavior:

pub trait ApiClient: Clone + MaybeSend + MaybeSync + 'static {}

No method, no error, no claim about any protocol. The send lives on Speaks<Proto>, one impl per protocol a client speaks, and the error rides with it: the failures are the client’s own — a name that would not resolve, a handshake that failed — so they are declared where the send is, once per client per protocol. The signature follows the lane:

pub trait Speaks<Proto: Protocol> {
    type Error: StdError;

    // blocking lane
    fn capi_send(&self, ctx: ApiContext, outbound: Proto::Outbound)
        -> Result<Proto::Inbound, SendError<Self::Error>>;

    // async lane, and every browser-wasm build
    fn capi_send(&self, ctx: ApiContext, outbound: Proto::Outbound)
        -> impl Future<Output = Result<Proto::Inbound, SendError<Self::Error>>> + MaybeSend;
}

For an HTTP-carried protocol — one the request pipeline hands a Request — the seat the pipeline bounds on is Supports<Cap>, and it is derived: Supports<Cap>: ApiClient + Speaks<Cap> has a single blanket impl covering every client that is both, so a hand-written impl Supports overlaps it and is refused. An endpoint’s Requires names the capability it needs, so a query against a client with no matching Speaks impl is a compile error at the call site — the capability plane chapter has the whole mechanism. A library stays generic over Client rather than naming an adapter, which is how one interface definition serves every adapter below. Bringing your own client is those two impls and nothing else; the Arc and Box blankets forward every protocol of the client they hold, and so do the family’s two client wrappers, capi_firewall’s Firewall<Client> and capi_debug_dump’s DebugClient<Client>, for every protocol whose outbound message is the framework Request.

The shipped adapters

AdapterClientCamp and laneConstruction
capic_reqwestReqwestClientnative; blocking, or async with its async featureReqwestClient::default(), or new(reqwest::Client) to bring a configured client
capic_ureqUreqClientnative; blocking only — it forbids the async axisUreqClient::default(), or new(ureq::Agent)
capic_native_clientNativeClientnative; both lanes. The family’s own sans-io engines over OS sockets — no third-party HTTP library in the exchangeNativeClient::new() / default()
capic_wasm_fetchWasmClientbrowser wasm only; async by nature of the platformWasmClient::new() / default() — a unit struct over the Fetch API
capic_reqwlessReqwlessClient<T, D>embedded no_std; async, and single-threaded is required. MSRV 1.91 for its embassy-net/smoltcp graphReqwlessClient::new(stack, dns) — generic over the TCP stack and the DNS resolver, so there is no Default

A client library does not pick among these. Native consumers name the adapter they want in their own manifest; the browser adapter ships with the client from a browser-wasm target table, because there is exactly one. Swapping adapters is a one-line change at the interface — the endpoints do not move.

The client capability table

Three compiler diagnostics point here. When a query needs a capability the client does not serve, or a scoped request asks for a budget knob the client cannot enforce, the error names the marker and says to check this table.

AdapterHttpWebSocketGrpc
capic_reqwestalwayswebsocket feature. Async lane: the upgrade is performed and Sec-WebSocket-Accept validated. Blocking lane: the impl exists so the code compiles, and the send fails with WebSocketRequiresAsync — reqwest’s blocking client has no upgrade APIgrpc feature, which implies trailers and therefore async. A dedicated HTTP/2 prior-knowledge lane, with grpc-status surfaced through the trailer cell
capic_ureqalwayswebsocket feature. The handshake runs over tungstenite on ureq’s TLS stack and yields a message-level connection
capic_native_clientalways, both laneswebsocket feature, both lanes — the genuine wire 101 handed to capi_websocket’s framinggrpc feature, both lanes — h2c by prior knowledge on http, ALPN h2 on https, trailers included
capic_wasm_fetchalwayswebsocket feature. The browser opens the socket; the send yields a synthetic 101 beside the message stream, and a refused upgrade surfaces as a connection error
capic_reqwlessalways

Neither capi_websocket nor capi_grpc is behind a feature of its own; the protocol crates are plain dependencies. The feature lives on the adapter that can perform the exchange, and a client library conventionally gates its own WebSocket or gRPC endpoints behind a websocket / grpc feature that pulls the protocol crate in. capic_native_client additionally speaks Redis (redis) and SIP with media (voip). Those are not capabilities: they never meet the request pipeline, so they have no Supports seat and no row in the table above — the client implements Speaks<Redis>, Speaks<Sip> and Speaks<Media> directly, and each exchange hands back a live session. The Protocol Core and the Two Planes is where that side of the client lives.

Timeouts, stalls, and cancellation

A request’s budget — the three knobs a scoped request can set — is enforced by the adapter, and adapters genuinely diverge. Three marker traits state what each client enforces: TimeoutCapable gates with_timeout, StallCapable gates with_stall_timeout, CancelCapable gates with_cancel, and asking a client for a knob it lacks fails at compile time with the missing marker named. A budget value that reaches an adapter through a path the markers cannot see — a channel-carried follow-up send, a hand-built context — is refused loudly, never silently dropped. The refusal rides the send’s own Refused arm rather than the adapter’s error, so it survives type erasure: QueryError::refusal() hands back a Refusal naming the client and the knob, and its kind is InvalidRequest, so nothing retries a send that will refuse again.

AdapterTimeoutCapableStallCapableCancelCapableMechanism
capic_native_clientboth lanesboth lanesboth lanesgoverned carriers beneath TLS: each wait bounded by min(stall, remaining) — a socket timeout re-derived per wait on the blocking lane, a sleep armed per await on the async one
capic_reqwestboth lanesasync onlyasync onlywhole call via reqwest’s per-request timeout (it travels with the streamed body); stall and cancel are the adapter’s own governed waits, which the blocking lane has no way to perform
capic_ureqyesnonoureq’s per-call timeout, and socket deadlines on the WebSocket handshake (cleared at the session handover). Neither other knob compiles against it
capic_wasm_fetchyesyesyesone AbortController covers all three; the governor’s per-wait sleep is min(stall, remaining) for the fetch and every chunk read
capic_reqwlessnot claimednot claimednot claimedembedded lane; every knob is refused before transmission

The two client wrappers forward whatever markers the wrapped client claims.

Why three markers and not one. The two time knobs measure different things, and an adapter can honour one without the other. with_timeout bounds the whole call — all wall time counts, the consumer’s own reading pace included — so any client with a per-request timeout can enforce it. with_stall_timeout bounds one wait on the peer, re-anchored whenever bytes move, so it needs a per-wait surface. ureq has phase timeouts, but they measure wall time through the phase; using them for a stall bound would fail a slow reader for the peer’s silence, which is the confusion the knob exists to prevent. So ureq claims TimeoutCapable and refuses the rest, and the compiler says so at the call site rather than the server saying so an hour into a download.

The deadline is a whole-call budget: with_timeout(d) is fixed as one absolute instant when the send starts, and every retry attempt, backoff sleep, and follow-up (next-page fetches included) measures against that same instant. Per-attempt bounding exists separately, as retry policy (RetrySpec::attempt_timeout), always tightened under the whole-call ceiling. FollowUpChannel claims nothing at all — it is a send channel, not a client — so the sugar exists only on clients, and the adapters’ typed refusals stand guard behind every channel-carried path.

Where the platform enters

Nothing above named a feature to pick a platform, because none exists: a native build takes the defaults, a browser build is cargo build --target wasm32-unknown-unknown, and std is the platform-capability switch on the browser rather than a library switch. Two consequences an adapter author meets: the browser owns cookies, so CookieMiddleware is a no-op there; and the browser enforces CORS, which no adapter can lift.

Two canaries in the family exercise the far ends of the model rather than merely compiling them:

  • nostd_canary_capi_rs boots four binaries on an emulated Cortex-M4 (QEMU mps2-an386) or, with --target riscv32imac-unknown-none-elf, a RISC-V hart — a scripted JSON exchange over the mock client, a real generated client replayed against embedded fixtures, the async lane under an embassy executor on a real SysTick/CLINT timer, and real HTTP/1.1 over capic_reqwless on an in-binary smoltcp loopback. Exit code 0 under cargo run is the verdict. Its .cargo/config.toml carries the --cfg getrandom_backend="custom" that bare metal needs, and the crate pins Rust 1.91 for the net lane’s embassy-net/smoltcp/reqwless graph.
  • runtime_canary_capi_rs drives the pipeline end to end under two executors the family links nowhere else — futures::executor::block_on, the bare poll loop with no reactor, and smol — with tokio absent from the whole dependency graph. That is what “runtime agnostic” is measured by.

The client as a swap point

Because the client is a constructor argument to the interface, it is also the seam for testing: swap the adapter for a ReplayClient and the same endpoints run against recorded fixtures with no network. That is the whole of the testing chapter, and the clearest payoff of keeping transport out of the endpoint.

Next: the capability plane — the typed vocabulary behind Supports<Cap> and Requires.

The Capability Plane: Lanes, Extensions, and Transport Vocabulary

Every endpoint names a lane — Requires = Http on an ordinary call, Requires = WebSocket on a socket, Requires = Grpc on a gRPC call — and a client is seated for each lane it can perform by an ApiClient impl beside a Speaks<Cap> one. This chapter is that plane end to end: how a lane is declared, how the compiler matches endpoints to clients, what a lane delivers to a decoder beyond the response, and the two ways back into the traversal — the door a decoder follows up through, and the capture surface that produces a request without sending it. It is a chapter for authors of transports, wrappers, and protocol add-ons; an everyday endpoint never touches it directly.

Capability and Supports

A capability is a zero-sized marker for a transport shape — a protocol seated on the HTTP request pipeline — and the trait on it says what one exchange of that shape produces and what the pipeline owes it:

pub trait Capability: Protocol<Outbound = Request, Inbound = Self::Response> {
    type Response: ResponseEnvelope;                // the plain Response, or (Response, extension)
    fn prepare_request(head: &mut RequestHead, ctx: &ApiContext) {}   // protocol setup; default no-op
    fn follow_up_channel<Client: Supports<Self>>(client: Client) -> FollowUpChannel<Self>;
}

The supertrait is where the request envelope is fixed. Every capability is seated on the pipeline, and the pipeline hands it a Request — so a capability declares only its response envelope, and a bare Cap: Capability bound is already proof that Cap’s outbound message is the framework Request. The floor’s Protocol is the more general contract underneath: a marker binding an Outbound to an Inbound, with no pipeline in the picture at all.

prepare_request runs during request assembly, before any pipeline member, the seal, or a fixture recorder sees the head, so what it writes is part of the signed and recorded request. It carries capability-wide mechanics only — the WebSocket marker writes its upgrade headers here — while request facts that depend on the endpoint belong to the decoder’s own preparation hook (the decoder contract). follow_up_channel is the lane’s answer to “can a decoder send again through you?”, covered below.

A client advertises a lane with two impls, and the second is where the one send in the system lives:

impl ApiClient for MyClient {}

impl Speaks<Http> for MyClient {
    type Error = MyClientError;

    fn capi_send(&self, ctx: ApiContext, request: Request)
        -> Result<Response, SendError<Self::Error>>;           // async: impl Future<..> + MaybeSend
}

The seat the pipeline bounds on follows from them:

pub trait Supports<Cap: Capability>: ApiClient + Speaks<Cap> {}

impl<Cap: Capability, Client> Supports<Cap> for Client where Client: ApiClient + Speaks<Cap> {}

The seat has no method of its own, and nothing implements it directly — a hand-written impl Supports<Cap> for MyClient overlaps the blanket and is refused, which is what makes “a client is a client that speaks the lane” true by type rather than by convention. The error is the Speaks impl’s, one per client per protocol; a client speaking several conventionally reports them all through one error type, and a consumer bounded on two names the one it means with <Client as Speaks<Cap>>::Error. There is no implication hierarchy between lanes: providing Http says nothing about Grpc, and a gRPC-only adapter that speaks neither plain REST nor WebSocket declares only the marker it holds.

The two meet at the query funnel. Every query entry point is bound Client: Supports<Endpt::Requires>, so handing a plain-HTTP client a WebSocket endpoint is a compile error at the .query() call site — and a curated one: the trait carries a #[diagnostic::on_unimplemented] message naming the client and the lane, and pointing at the adapter feature that would enable it (the client capability table).

The envelope and the typed extension

The subtle part of the plane is how a lane delivers more than a response to its decoder without widening the common path. The traversal’s members are Response-typed — the lane rides to the wire, not through the phases — so the lane’s Response envelope must split:

pub trait ResponseEnvelope {
    type Extension: Default + MaybeSend + 'static;
    fn into_parts(self) -> (Response, Self::Extension);
    fn from_parts(response: Response, extension: Self::Extension) -> Self;
}

Two shapes ship and cover the vocabulary. The plain Response is the response-shaped envelope, Extension = (), for a lane whose exchange yields nothing beyond the response. The pair (Response, Ext) is the extended envelope, for a lane with an out-of-band deliverable. A lane names whichever fits, and the split and merge come with the type; a protocol whose envelope is its own struct implements the trait directly and gets the same machinery.

At the adapter’s return the traversal calls into_parts, unwinds the response half through the members, and holds the extension aside; the decoder then receives it by value, exactly once, as the extension parameter of its decodeExtensionOf<Cap> is the alias signatures use. The Default bound is what an exchange yields when a member answers without the wire being reached: a cache hit under Http yields (), under WebSocket an absent connection, which reads as a refused upgrade rather than an error at the seam.

The shipped lanes

Http (capip_http) is the plain exchange: Request in, Response out, no preparation.

Grpc (capip_http) is response-shaped too — the status trailers ride the response head in its TrailerCell — but the marker promises more than a shape. A client declaring it holds an exchange whose request body is read lazily and never drained (a client-streaming source is never materialized whole); whose te: trailers is the adapter’s own, an application-set one refused; whose response head carries a trailer cell the adapter fills at body EOF, so grpc-status is always readable; whose grpc-timeout is derived from the context deadline through Grpc::timeout_header_value, never from the endpoint; and which is half-duplex — the response is returned once the request reaches EOF or the server stops the upload. The transport realization is the adapter’s: native HTTP/2, or a complete gRPC-Web mapping in the browser. A client that cannot surface trailers cannot declare the marker truthfully.

WebSocket (capi_websocket) is the extended lane, and it lives in its own crate because its envelope names that crate’s connection types. The extension is Option-wrapped, because a server may refuse the upgrade with an ordinary response — a 401 challenge, a redirect — that decoders and members still need to see: a 101 Switching Protocols comes back as Some, anything else as None with the decoder reporting the failed handshake from the response itself. The envelope differs by platform, which is the one place the plane is target-dependent:

TargetResponse envelopeprepare_request
native and WASI(Response, Option<UpgradedConnection>)generates a WsKey from the context’s RNG and writes Connection, Upgrade, Sec-WebSocket-Version, Sec-WebSocket-Key; the adapter validates Sec-WebSocket-Accept against it
browser wasm(Response, Option<Box<dyn WsMessageStream>>)none — the browser owns the handshake and the framing

A WebSocket endpoint’s code is the same on both; only the lane’s envelope changes underneath it.

The door: sending again from inside a decode

A decoder that needs another exchange to finish — the next page, a form submission, a resumed stream — is not handed a client. It is handed a door, Access: FollowUpAccess<Endpt::Requires>, captured on the endpoint’s own lane. FollowUpAccess has two methods, the lane send and detach(), and a hidden marker supertrait: the set of doors is the framework’s, and an exchange composed anywhere else is not expressible. What an implementor supplies is the wire and only the wire — every invariant that must hold on a transmitted request lives inside the traversal, where no re-entry path can rebuild it.

The doors are two kinds. A borrowed door holds a concrete client — the one the interface named, or the one a runner was handed — so its error type stays concrete all the way to the query boundary. The detached door is a FollowUpChannel: the owned, 'static, erased form detach() returns, which an output that outlives the decode call (a page iterator, a resumable stream) carries with it. The channel implements neither ApiClient nor Supports, so nothing can seat it where a client belongs; its one inherent operation is a bare send, and the way to use it is the scope.

The scope

FollowUp — imported explicitly as capi_transport::FollowUp (or capi_rs::transport::FollowUp); it rides no prelude, because the import is the “this is unusual” signal — opens a scope on any door:

use capi_transport::FollowUp;

let page   = client.follow_up(config.clone(), &context).fetch_page(next)?;   // a paged continuation
let output = client.follow_up(config.clone(), &context).query(endpoint)?;    // decoded through its own decoder
let rsp    = client.follow_up(config.clone(), &context).exchange(endpoint)?; // the undecoded ApiResponse

Each runs one full traversal for the follow-up endpoint: prepared against the parent context, planned from this endpoint’s own rate limiter and signing, and traversed over the config’s pipeline through the door’s captured lane. The parent ApiContext is a required parameter — the follow-up derives its context from it, inheriting the budget (deadline, stall bound, cancel token) and the lineage (extensions, notification sender) while taking a ContextId of its own. A primary call site has no parent context to supply, which is the deterrent against routing a primary request through a door. The caller’s RequestOverrides ride the context too, and every follow-up applies them freshly to its own assembled request, exactly once.

exchange is the custom-decode seam: the ApiResponse it returns carries the follow-up’s own derived context and the door’s detached form, so decoding it can issue further follow-ups — the HTML form flow submits through it. without_auth_overrides() on the scope opens it as a credential exchange: the transport overrides forward, the auth overrides drop, and the traversal runs as a mint (below).

The scope is typed by the door’s lane, and its methods accept only endpoints requiring exactly that lane — a Grpc endpoint through an Http door is a compile error at the call site. They also exist only when the lane is response-shaped, Cap: Capability<Response = Response>. A door for an extended lane can be held and stored but has no queryable surface at all, which is the honest answer to “send a WebSocket upgrade through a channel that returns a Response”: the connection would have to be discarded.

follow_up_channel and the honest choice

That is what a lane’s follow_up_channel decides. A response-shaped lane returns FollowUpChannel::with_capability(client), which captures the client’s send behind an Arc and a type-erased closure — the Supports<Cap> bound is proved once, at the site that knows the concrete client, which is why the channel needs none. The construction bound is Cap: Capability<Response = Response>, so an extended lane physically cannot take it; it returns FollowUpChannel::unsupported(), the empty capture that refuses every send with an error naming the lane rather than performing a lossy exchange. In practice that channel is never reached — the decoders that follow up are all on response-shaped lanes — but the refusal is typed and loud, not silent.

The three entries

A traversal runs one of three ways, chosen by constructor so no caller can spell an entry the framework does not offer. Traversal::new is the ordinary exchange: every phase, the gate, the seal, the wire, the unwind. Traversal::credential_exchange is a mint — the phases and the transmit invariants run exactly as for any exchange, but the seal places whatever the store holds without renewing it, and the gate is skipped, since a config’s limiter models the API service rather than the identity provider and queueing a mint behind the requests waiting for it can deadlock freshness. Traversal::capture is the shape of an exchange without the exchange: the phases, the seal, and the transmit invariants, no gate (a limiter meters real exchanges), and no response, so no unwind. The scope picks the first two; the capture surface is the third.

The capture surface: a request without a send

Some carriers put a request inside another message — a frame on an upgraded connection, a queue payload, a part of a batch envelope. They need the exact wire-form request a solo send would have produced, and no send. Finalize is that, sealed to ApiRequest and, like FollowUp, imported explicitly:

use capi_transport::{Finalize, PackagedRequest};

let mut prepared = ApiRequest::prepare(config.clone(), endpoint)?;
prepared.apply_overrides(&overrides);
let request  = prepared.finalize()?;                 // the wire-form Request, body stream still live
let packaged = PackagedRequest::drain(request)?;     // head + owned bytes, for the carrier's own message

finalize runs everything short of transport: the request phases, credential placement, service signing and cosigning, hop-by-hop validation, the Host strip. FinalizeError names what stopped it — Prepare, Middleware, or ShortCircuited { at } when a member answered the request, a cache hit say, which at a capture site is a programming error rather than a service condition: keep answering members off any pipeline a captured request is finalized against. The one-step sugars config.finalize_request(endpoint) and finalize_request_with(endpoint, &overrides) fuse preparation and finalization, and a carrier that needs nothing but the finished request takes either.

A PackagedRequest is the drained form: the finalized head whole — method, URL, headers, and the framework’s request specs, so masking vocabulary and the credential classification stay reachable — plus the body as owned bytes. drain refuses a live stream with DrainError::StreamingBody rather than buffering something unbounded to fit it in a frame. There is no Option on the body: an absent body is head.specs().body_source == BodySource::Empty, and which of “empty” and “absent” a carrier’s message spells is the carrier’s decision.

A PackagedResponse is what a carrier lifts an answer into: the head it can assemble from its own message — a status, the headers that message carried — plus the answer’s bytes. That pair is exactly what an endpoint’s body decoder reads, beside the config and the context the carrier supplies. Decoding one is decode fidelity, not pipeline fidelity: response members, transfer stages, and client wrappers observe an unwind, and none ran.

Which context decodes a packaged reply is one rule, stated in capi_transport::capture’s module docs: a follow-up of the carrier’s own exchange context — the envelope send for a batch, the upgrade for a socket — so the decode inherits that exchange’s deadline, stall bound, cancellation, extensions, and notification lineage.

Splice or drain is settled by what the carrier assembles: one that splices a live body stream into its own message keeps the finalized Request and never drains — the batch envelope is that carrier — and PackagedRequest is the drained form for one that needs owned bytes inside its message.

Three things a captured request is not. It is not metered: the gate meters egress, a packaged request may be built and never sent or sent twice, and what leaves the process is a message on the carrier’s channel, metered by the carrier’s own limiter. It is not firewall-checked: the policy sees the request that opened the carrier — the upgrade URL, the envelope’s URL — and a carrier whose tunneled targets go elsewhere is outside the firewall’s contract. And it is not masked at the carrier’s layer: the packaged request keeps the head’s classes, but the carrier’s own message has no structural mask. The WebSocket session and the batch envelope are the two shipped carriers.

Wrappers

A wrapper is a client that holds a client. The framework forwards lanes through Arc<Client> and Box<Client> with one blanket each — an Arc<Client> supports exactly what Client supports, no per-lane maintenance — and its two shipped wrappers follow the same shape.

Firewall<Client> (capi_firewall) checks every send’s destination against an EgressPolicy before the wrapped client sees it:

let policy = EgressPolicy::allow(["*.parcel.example"])?     // exact hosts, or *.apex at label boundaries
    .require_https(true)                                    // default: any scheme
    .mode(Mode::Enforce);                                   // default; ReportOnly logs what it would block
let api = ParcelApi::new(Firewall::new(reqwest_client, policy));

Its Speaks<Proto> impl is generic over every protocol whose Outbound is the framework Request — the bound is load-bearing, because the check reads the head’s URL and an opaque message it could not inspect would pass unchecked — and the inner client is unreachable by construction: no accessor, a private field, and a Debug that omits it. The opacity survives erasure. When a lane mints a follow-up channel, the channel captures the seated client’s send — the firewalled one — and exposes no way back, so a decoder’s next page is checked by the same policy. It forwards the budget markers (TimeoutCapable, StallCapable, CancelCapable) of the client it wraps, so a with_timeout does not die at the boundary. Wrap innermost, in your own code, and disable the transport’s native redirect following: a 3xx the transport followed internally would be fetched unchecked, while the framework’s redirect runner re-enters the wrapped client on every hop.

DebugClient<Client> (capi_debug_dump) is the same shape for a different purpose: it writes down every exchange that passes through it — request head, response head, trailer block, as much of each body as asked — in the canonical text format the test fixtures use, unredacted, credentials included. It is a development tool; treat a dump as the secret it contains.

The taxonomy

The capability plane sits alongside four kinds of request-shaping machinery, and knowing which is which says where a new concern belongs:

  • Planes — the rate-limit gate and the seal (resolution, placement, signing). Fixed, config-fed, always in the same place in the traversal.
  • Pipeline members — optional, phase-seated, run once per attempt (Middleware).
  • Runners — endpoint-owning re-dispatchers above the decoder (Runners).
  • Adapters and wrappers — the wire, and the clients that hold clients: opaque to everything above them, checked or recorded per send.

One primitive threads through all four: capi_notification’s typed broadcast (NotificationSender / NotificationReceiver, LogNotification), wired into ApiContext so a subsystem can emit a structured event — a traversal step, a stream’s reconnect, a transfer stage’s progress — that bubbles up through derived contexts to whoever subscribed on the base one.

Two checklists

Adding a lane: declare a unit-struct marker and implement Protocol and Capability for it; name the envelope — the plain Response, or (Response, extension) for an out-of-band deliverable; write prepare_request if the wire needs a handshake; return FollowUpChannel::with_capability from follow_up_channel for a response-shaped lane and FollowUpChannel::unsupported for an extended one; seat the true adapters with Speaks<YourLane>, validating the handshake where it is performed; and have a decoder receive the typed extension.

Adding a pipeline member: implement RequestMiddleware, ResponseMiddleware, or ExchangeMiddleware; seat it in a phase from the config’s middleware; and override serves_lane only if the member is genuinely wrong on some lane. A concern that needs to send again is a runner, and a concern that needs to send from inside a decode goes through the door.

This is the machinery under “write the endpoints, ship everywhere”: the type system, not runtime dispatch, guarantees an endpoint only runs on a transport that can perform it. The part closes with the outside view — consuming and patching a client.

Consuming and Patching a Client Downstream

This chapter is the outside view: you are using a client someone else built on Capi, not authoring one. The properties that make a client a description rather than code are also what make it pleasant to consume — and, when it lags the service or misdescribes an endpoint, to fix in your own crate without waiting on a release.

Consuming: the whole surface

Using a published client is three steps — pick a client adapter, construct the interface, authenticate, and query:

use some_api_rs::{SomeApi, endpoints::GetThing};

let api = SomeApi::new_with_defaults(capic_reqwest::ReqwestClient::default());
api.authenticate(std::env::var("SOME_API_KEY")?);
let thing = api.query(GetThing::new("id_123")).await?;

new_with_defaults takes the batteries-included codecs behind the client’s default-codecs feature; new takes your own. authenticate is the credential flow the client library chose — a bearer token here — and a client with several credential tiers exposes each as a role (api.management().authenticate(..)). Two calling styles exist and are one path:

let thing = api.query(GetThing::new("id")).await?;    // interface-first (inherent)
let thing = GetThing::new("id").query(&api).await?;   // endpoint-first (EndpointQuery, from capi_extras)

Anything that opens a scoped request works on the endpoint-first side too, so endpoint.query(api.with_timeout(d)) reads as well as the other way round.

Reading a failure

Errors arrive as one type-erased QueryError, and its accessors are the consumer’s whole triage vocabulary:

QuestionAccessor
What class is it?kind()ErrorKind; the predicates is_api(), is_auth(), is_decode(), is_io(), is_transport(), is_invalid_request(), is_status_client(), is_status_server(), is_rate_limited()
Should I try again?is_retryable(), and retry_kind() for how — a RetryKind separates a transient failure (back off) from throttling (wait the delay it named) from a terminal one
What did the service say?status(), metadata() (the service’s own error code and message, its request id), response_head() (the observed head, when a head-capturing runner kept it)
What did the service mean?api_error::<SomeError>() — the client’s typed error, recovered by downcast
Which knob fired?is_timed_out(), is_stalled(), is_cancelled(), refusal()
Whose fault at the wire?client_error(&api) — the adapter’s own error, its type inferred from the interface
Did my own adaptation fail?convert_error::<E>() for an IntoEndpoint conversion, map_output_error::<E>() for a MapOutput remap

Every accessor is a reference into the same error, so a match arm can ask several questions of one value before deciding. to_log_string() is the one-line form for a log.

Runner verbs a consumer applies

Without touching the client, a consumer reshapes a call at the runner layer, and the verbs read as a sentence:

let raw = api.with_raw_response().query(ep).await?;       // ApiResponse — skip decoding, inspect bytes and headers
let (head, thing) = api.with_headers().query(ep).await?;  // (ResponseHead, T) — the decoded value plus its head
let wrapper = api.wrapped().query(ep).await?;             // the wire wrapper of a response-unwrap endpoint

let thing = api
    .retry(RetrySpec::default())            // RetryRunner: backoff on retryable failures; retry_if(spec, |e| ..) for your own predicate
    .retry_after(RetryAfterSpec::default()) // RetryAfterRunner: obey a 429/503's Retry-After
    .follow_redirects(3)                    // RedirectRunner: up to n hops, re-described at each
    .refresh_on_unauthorized()              // RefreshRunner: one credential refresh on a 401, then resend
    .with_headers()
    .query(ep)
    .await?;

let done = api.wait_until(WaitSpec::default(), |job: &Job| job.is_finished()).query(GetJob::new(id)).await?;

with_raw_response and wrapped seat a response terminal — one per chain — while with_headers mounts an observer over whichever terminal is seated, and the re-dispatching verbs (retry, retry_after, follow_redirects, refresh_on_unauthorized, wait_until) nest: each wraps the runner beneath it, and the order you write is the order they fold. progress(..) and throttle(..) seat a transfer stage on the same chain. The composition rules — what an observer sees, which terminal pairs with which endpoint — are the runners chapter’s.

A failed call keeps its head. with_headers() attaches its head clone to the error arm as well, and QueryError::response_head() reads it back — which is how a consumer gets a Retry-After off a 503, or the trailing metadata a gRPC peer attaches to an UNIMPLEMENTED, without dropping to with_raw_response():

match api.with_headers().query(ep).await {
    Ok((head, thing)) => { /* … */ }
    Err(error) => {
        if let Some(head) = error.response_head() {
            let retry_after = head.headers.get("retry-after");
            // for a gRPC refusal the trailer cell is shared with the head,
            // and it has filled by the time the error reaches you
        }
    }
}

The per-request knobs beneath the runners are on the scoped request — api.scoped() opens one — and they are the consumer’s without any verb: with_header / append_header / without_header, with_rate_limit / no_rate_limit, with_auth / no_auth / as_role, with_user_agent, with_middleware and its response and exchange forms, with_transfer_stage, with_notifications, and with_context_extension. Every verb above returns that same scoped request, so the knobs and the verbs interleave freely.

Bounding a call: timeouts and cancellation

Three per-request knobs give a call a budget — TimeoutExt, StallExt, CancelExt, all in the prelude — and they differ in what time counts:

use capi_rs::prelude::CancelToken;

// Whole call: "this must resolve by T". All wall time counts.
let thing = api.with_timeout(Duration::from_secs(10)).query(GetThing::new("id")).await?;

// Each wait on the peer: "no single silence longer than this".
let feed = api.with_stall_timeout(Duration::from_secs(5)).query(StreamEvents::new()).await?;

// Both, for both guarantees.
let page = api
    .with_timeout(Duration::from_secs(60))
    .with_stall_timeout(Duration::from_secs(5))
    .query(ListThings::new())
    .await?;

let token = CancelToken::new();          // fire token.cancel() from anywhere
let call = api.with_cancel(token.clone()).query(GetSlowThing::new("id"));

Unary and SLA-bound calls take with_timeout; long streams and pagination take with_stall_timeout; combine them when you want both guarantees.

with_timeout(d) is a whole-call budget: the duration becomes one absolute deadline when the send starts, and everything under the call — every retry attempt, backoff sleep, and follow-up page fetch — measures against that same instant. Because all wall time counts, so does the consumer’s own pace: a stream you read one line per minute exhausts a ten-second deadline however healthy the server is. Right for an SLA, wrong for a long download.

with_stall_timeout(d) bounds each individual wait on the peer — the dial, the response head, every body chunk — re-anchored at each one. Consumer think-time never counts, because nothing is armed while nobody is waiting; and a peer that trickles steadily never trips it, because each chunk does arrive inside d. Set both and every wait is bounded by min(stall, remaining).

A knob that fires ends the call with an outcome the framework owns rather than one the adapter invented, so the same code reads it whichever adapter is underneath:

match call.await {
    Ok(thing) => { /* … */ }
    Err(err) if err.is_cancelled() => { /* the token fired — do not retry */ }
    Err(err) if err.is_timed_out() => { /* the whole-call deadline passed */ }
    Err(err) if err.is_stalled() => { /* one wait on the peer ran long */ }
    Err(err) => match err.refusal() {
        Some(refusal) => { /* this client cannot enforce a knob you set */ }
        None => { /* an ordinary failure — triage with kind() */ }
    },
}

Each of the first three is true whether the knob fired before the response head arrived or while the body was still coming — the same event, noticed at different moments — and the predicates are the only way to tell a timeout or a stall from a dropped connection, since both keep ErrorKind::Transport and stay retryable. A fired token does not: is_cancelled() is ErrorKind::Cancelled, which is_retryable() rejects, so a retry runner stops at once instead of sleeping through its backoff schedule against a token that will never un-fire.

The knobs are gated on the client’s capability markers, so they only compile against a client that enforces them — the adapter table says who enforces what. A budget that reaches an adapter by a path the markers cannot see is refused rather than dropped: refusal() hands back a Refusal naming the client and the knob, the kind is InvalidRequest, and nothing was sent.

Patching an endpoint without forking

An endpoint is an ordinary struct bound to an ApiConfig, so a new endpoint in your crate can wrap an imported one and change only what is wrong. The tool is OverrideEndpoint: name the wrapped type, hand back the two accessors, and every request-construction method defaults to delegating to it, so the impl states exactly its departures. grok’s Deferred<T> is the shipped model — the same chat request, asked to return a polling handle instead of a completion:

#[derive(Debug, Clone, Default)]
pub struct Deferred<T: DeferrableChat>(pub T);

impl<T: DeferrableChat> OverrideEndpoint for Deferred<T> {
    type Endpt = T;
    type Output = GetChatDeferredCompletion;                    // a different response shape
    type Error = GrokError;
    type Decoder = BodyDecoder;
    type Requires = <Self::Endpt as Endpoint>::Requires;        // the lane is the inner endpoint's

    fn endpoint_ref(&self) -> &Self::Endpt { &self.0 }
    fn endpoint(self) -> Self::Endpt { self.0 }

    fn body(self, config: &InnerConfig<Self>, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        #[derive(Debug, WireModel, WireEncode)]
        struct DeferChat<T> {
            #[wire(flatten)]
            endpt: T,
            deferred: bool,
        }
        config.json_lib().encode_body(&DeferChat { endpt: self.0.wire_body_fields(), deferred: true })
    }
}

impl<T: DeferrableChat> DecodeBodyFn for Deferred<T> {
    fn decode_fn() -> BodyFnDecoder<Self> { GrokResponse::decode }
}

The five associated types are required — Output, Error, Decoder, and Requires forward with <Self::Endpt as Endpoint>::X when they do not change — and InnerConfig<Self> names the wrapped endpoint’s config, so an override speaks the same codecs and base URL. From this seam you add a body field the upstream crate omitted, change the URL or the method, add a header, apply a tighter rate limit, or swap the decoder. The override is an Endpoint in its own right through a blanket impl, so api.query(Deferred::new(chat)) needs nothing else; and because it wraps rather than copies, an upstream fix to the inner endpoint reaches you on the next update.

Derived inputs: adapting the call shape

A related seam reshapes the input rather than the endpoint. IntoEndpoint lets query() accept your own type — an app-specific struct with a fallible conversion into the real endpoint:

impl IntoEndpoint for ThingByName {
    type Endpoint = GetThing;
    type Error = LookupError;
    type Remap = remap::No;               // remap::Yes to also reshape the output through MapOutput
    fn into_endpoint(self) -> Result<GetThing, LookupError> { /* resolve name → id */ }
}
// now: api.query(ThingByName { name }).await?

With remap::Yes and a MapOutput impl the input also transforms the output (StreamIterator::try_map is the streaming equivalent); conversion and mapping failures surface as QueryError::convert_error and map_output_error. The flag is sealed to those two markers, so a third state can never fall outside the pass-through blanket. Reach for OverrideEndpoint to change what an existing endpoint sends, and IntoEndpoint to adapt a foreign input (or output) into the call; both keep the adaptation in your crate.

Patching the crate, not the endpoint

Sometimes the fix belongs upstream and you want to build against your own checkout of the client — or of a framework crate — while it is in review. Family crates depend on each other by plain version requirement, never by path, so a checkout resolves each dependency from crates.io unless a patch table above it says otherwise. cargo capi patch (from capi_verify’s cargo-capi binary) writes that table:

cargo capi patch

Run it at the directory holding the checkouts, never inside one — it refuses a directory that is itself a cargo workspace, so the table sits above every repo and inside none of them — and --only <dir>,<dir> narrows it to the checkouts you name. Cargo reads .cargo/config.toml from the current directory upward, so one table reaches every repo beneath it, rust-analyzer and trybuild scratch projects included. The table is never committed: an entry names a filesystem path, so a committed one would point a consumer’s clone at directories that do not exist and suppress the registry resolution that should have happened. And one table serving several repos over-patches by construction — cargo reports the entries a given build does not reach as unused on every command, which is expected.

Extending

When the fix is not a patch but a new piece — a codec, a transport, an auth scheme or signer, a middleware, a whole client — capi-extension-template is the starting point: cargo generate --git https://github.com/capi-rs/capi-extension-template renders a crate with the family’s feature lanes, the alignment guard, the documentation skeletons, and a [package.metadata.capi] table already in place, so cargo capi verify is its gate from the first commit. The capi-rs-author skill covers the authoring patterns — config, interface, auth, endpoints, decoders, errors, and replay tests — and is where a new client library starts.

Next: testing — how a client author, and you, verify all of this offline.

Replay Testing with ReplayClient

The client is a swap point, and testing is where that pays off most: seat a ReplayClient in place of the real adapter and your endpoints run against recorded fixtures — offline, deterministic, no network. capi_test_framework makes this the default way to test a Capi client, and this chapter is the loop: write a test once, record it against the live service, commit the fixtures, replay them forever.

One test body, three runtimes

#[capi_test] writes a test once and emits the shape for whichever lane is building — exactly one compiles per configuration:

  • native sync#[test], with async and every .await stripped;
  • native async#[tokio::test];
  • browser wasm#[wasm_bindgen_test].
#[capi_test]
async fn test_get_models() {
    let api = GrokTestHarness::setup(current_test_name!());
    let models = api.query(GetModels::new()).await.unwrap();
    assert_eq!(models.len(), 3);
}

The .await you write is real in async builds and stripped in sync builds, so the same source verifies the client on every lane without a cfg in sight; a helper that must compile both ways takes #[maybe_async], the crate’s strip_await/identity pair aliased by cfg. current_test_name!() names the fixture directory after the enclosing function, so the literal and the name cannot drift apart.

The harness

A small harness in tests/common.rs wires a ReplayClient into the interface. grok’s is the shipped model:

#[derive(Debug, Copy, Clone, Default)]
pub struct BasicConfig;

impl From<BasicConfig> for ReplayConfig {
    fn from(_: BasicConfig) -> Self {
        ReplayConfig::default()
            .classify_header("cookie", FixtureClass::Omitted)
            .with_sensitive_env_prefix("GROK_")
            .with_sensitive_file("tests/sensitive.local.env")
            .with_sensitive("api_key", "xai-DUMMY_KEY")
            .with_sensitive("team_id", "00000000-0000-4000-8000-000000000002")
    }
}

pub fn setup_interface(client: ReplayClient) -> GrokApi<ReplayClient> {
    let context = client.context();                    // the recorded clock and RNG
    let mut api = GrokApi::new_with_defaults(client);
    api.set_base_context(context);                     // every request derives from it
    api
}

pub struct GrokTestHarness;
impl GrokTestHarness {
    pub fn setup<T: ToString>(name: T) -> GrokApi<ReplayClient> {
        let api = setup_interface(ReplayClient::for_test(BasicConfig, name));
        api.authenticate(api.sensitive("api_key"));   // the placeholder in replay, the live key when recording
        api
    }
}

Three things make it deterministic. ReplayClient::for_test(config, name) resolves the conventional root — tests/data, or CAPI_FIXTURE_ROOT — and loads tests/data/<name>/. client.context() is installed as the config’s base context, so every request derived from it inherits the recorded clock and RNG: a polling loop sleeps in mock time, a PKCE verifier or a multipart boundary is drawn from the recorded seed, and the replay matches byte for byte. And the config marker converts into a ReplayConfig that declares what each recorded item is — the next chapter has the full vocabulary.

api.sensitive("api_key") is the record-versus-replay idiom for a credential. with_sensitive(name, placeholder) registers the placeholder that fixtures hold and that replay returns; when recording, the live value comes from <prefix>API_KEY in the environment or the gitignored sensitive.local.env file, and a missing one fails before any traffic. An unregistered name panics in every mode, so a typo surfaces in replay too.

Record or replay, by fixture presence

There is no construction-time choice between the two. for_test scans the fixture directory: fixtures present, the client replays; none present, it records live traffic there. So the first run of a new test records and every run after it replays, and re-recording is deleting the directory. is_recording() reports which mode a run took, and .mock_only() forces replay even when fixtures are missing, for a test that must never reach the network.

Recording needs a live client, and which one is bundled depends on the lane:

LaneBundled live client
native, reqwest featurecapic_reqwest
native, native-client feature (without reqwest)capic_native_client
browser wasmthe Web Fetch client, no feature needed
everything else — plain std, single-threaded, bare metalnone: new_with_backend is replay-only, and new_with_client_and_backend records through a client you supply

reqwest and native-client are each mutually exclusive with single-threaded — the crate refuses the pair with a compile_error! naming the remedy — so a native recording suite runs --features std,async,reqwest and a current-thread suite runs --features std,async,single-threaded and replays committed fixtures. The fixture backend is a seam of its own: FsFixtureBackend on native, NodeFsFixtureBackend on browser wasm under Node, and InMemoryFixtureBackend for a fixture set built in the test itself.

The fixture format

Fixtures live in tests/data/<test_name>/, one numbered exchange per request, consumed in order:

tests/data/test_get_models/
  0001.req      # the request the client should make
  0001.rsp      # the response to play back
  0001.meta     # what replay needs and HTTP does not carry

A .req or .rsp holds the exchange and nothing else, in the canonical text form capi_http_types::text defines — the same rendering capi_debug_dump writes, so a dump and a fixture read alike. The head is a request line of method plus complete absolute URL, or a status line of version plus numeric status, then one lowercase name: value line per stored field, then the blank line the body follows. No host line (the URL carries the authority), no request version, no reason phrase:

GET https://api.x.ai/v1/models
accept: application/json
authorization: [REDACTED]

HTTP/2.0 200
content-type: application/json

{"data":[{"id":"grok-4"}]}

The body is the raw bytes after that blank line, so a fixture may carry arbitrary binary; a body kept in a file of its own (BodyEncodingMode::File, or Auto deciding from the content type), or captured chunk by chunk with the moment each arrived, is named by the sidecar instead. A response’s trailer block is a standalone 0001.trailers file, because it is only known once the body reaches EOF, and it replays as pending until the body does — which preserves the distinction a live trailer-capable client exhibits.

Everything else rides 0001.meta, a small versioned key=value grammar the test framework owns, so reading a fixture pulls no JSON or TOML codec into the graph:

capi-meta 1
request.date=2026-01-01T00:00:00Z
request.masking=masked
response.date=2026-01-01T00:00:00.150Z
response.masking=masked

The dates are the recorded wall clock that drives the injected context: the first request’s pins it, and each match advances it to that exchange’s response. Every other key has a default and appears only when it differs — where a body lives, the recording RNG’s seed, the URL fragment a head does not carry, whether the match key asserts the recorded authority, how a streaming request body ended, and the masking attestation and capture marker the next chapter explains.

Ordered consumption is what makes multi-step flows testable: a reconnect, a poll loop, or an OAuth2 exchange records as 0001, 0002, … and replays request by request, and the engine supports duplicate keys — create, modify, retrieve against one endpoint each consume the next matching entry. A request that matches nothing fails loudly, with with_write_unmatched_requests(true) writing the offending request to disk for comparison.

By default a recorded response keeps only the headers the response policy classes Plain — payload-shape headers such as content-type and content-length, caching validators, the WebSocket upgrade set. with_record_all_response_headers() keeps everything, for debugging.

Time in a replay

The mock clock is frozen by default — this and sleeping on it are the only things that move it — so two timestamps taken with nothing in between are the same instant, and an assertion about elapsed time is exact. The client exposes the knobs: advance_clock(d) moves it by hand, set_auto_advance(d) gives every read a step so successive timestamps differ, set_system_time(t) pins it, and use_live_clock(true) hands the wall clock back. A test that reads the clock many times without moving it trips a spin detector; set_stall_threshold raises the bar for one that legitimately does.

A polling test is the common case. capi_test_framework::wait_until(clock, spec, step) sleeps interval on the clock between iterations and gives up with WaitTimeout once timeout elapses — instantly in replay, since sleep advances mock time rather than waiting, and at real intervals when recording, so the fixtures pace as the service did. wait_until_done is the same loop over an endpoint that reports a status. And client.mock_timeout(d, future) races a future against the mock clock, so a test can prove a hang is caught without waiting for one.

Recording the twin

For an endpoint whose sensitive fields ride the body, record through api.query_redacted(endpoint) instead of query. It dispatches the live request as usual, but the fixture the recorder writes — and the request the replay engine compares — is a twin built from a redacted clone of the endpoint and finalized with its credentials and signatures redacted by the seal. Nothing in the twin ever held a live value, so nothing has to be found and masked after the fact; it asks the endpoint to be Clone and ApiRedact, which the wire derive provides (Redaction). Seat RedactedRunner under a runner chain to combine it with paging or polling.

Testing a decoder directly

For a decoder you want to exercise without fixtures at all — a 429 on the third call, a body truncated below its Content-Length, a server that hangs — capi_test_core’s MockClient scripts responses and the faults a recording cannot express:

let mock = MockClient::new()
    .on(Method::GET, "/shipments/shp_123")
    .respond(MockResponse::ok().bytes(body, "application/json"))   // first call
    .then_fault(Fault::Connect)                                    // second call: reset
    .default_response(MockResponse::not_found())                   // every other path
    .build();

Rules match in order, a rule’s chained outcomes script a per-call sequence whose last outcome repeats, and an unmatched request panics unless told otherwise. Fault covers Connect, ConnectionRefused, Timeout, Hang, Cancelled, and Empty; MockResponse::ok() / status(code) build a response with text, bytes, body_encoded, or event_stream, plus the delivery modifiers a recording cannot express — truncate_to(n), reset_after(n), malformed(), dribble(chunks, total), delay(d). MockClient implements ApiClient, so it seats in the same harness a ReplayClient does, and a MockClient::new() with no rules is the proof of a negative — hand it to a resumable decoder as FollowUpChannel::new(mock) and a stray reconnect trips the panic.

The gate

Every committed fixture attests how it was written, and one more test in the suite refuses the ones that must not land:

#[test]
fn fixtures_attest_masking() -> Result<(), Box<dyn std::error::Error>> {
    FixtureAudit::scan("tests/data")?.check()?;
    Ok(())
}

cargo capi fixtures runs the same audit from the command line, and cargo capi verify includes it. What the attestation says, and the three tiers that decide what a fixture may hold, is the next chapter; recording a WebSocket session as a .ws fixture is in WebSocket, and the per-message comparison a gRPC fixture gets — bodies to .bin sidecars, grpc-status in the trailer block, the twin as the route for a secret in a frame — is in gRPC.

Fixtures and Sensitive Data: The Classification Model

Recording a real exchange means writing a real request and a real response to a file you intend to commit. Everything in that exchange is a candidate secret: the credential in a header, the signature in a URL, the token in a response body, the client secret an error envelope echoed back. The framework’s answer is not a list of things to scrub. It is one question asked of every recorded item — what is this? — answered once, where the answer is known, and a gate that refuses to commit a file whose writer could not answer it.

One class per item

Every header, query parameter, and body field carries exactly one FixtureClass, and the class alone decides how it is persisted and how it is compared:

ClassRecorded asReplay compares
Plainverbatimexact
Volatileverbatimpresence and kind
Sensitivemasked placeholderpresence and kind
Omittednot recordednot compared
Requiredverbatimpresence, asserted even without a fixture

Plain is the default and needs no thought. Volatile is for values that change every run — a request id, a cursor, a user-agent — which must be there but will never be the same twice. Sensitive is for secrets: the value is replaced with a placeholder before anything reaches disk, and comparison never looks at it again. Omitted keeps an item out of the fixture entirely. Required is the one match-side class: it records like Plain, compares like Volatile, and additionally fails the match when the live item is absent — the way to assert that the client generated a correlation id or a signature header without pinning its value.

Two classes describe the data (Volatile, Sensitive) and mirror the marks a wire model declares; two describe the fixture (Omitted, Required) and exist only on the test side.

Four tiers, in order of preference

1. On the model

#[derive(WireModel, WireDecode)]
pub struct Oauth2Token {
    #[wire(sensitive)]
    pub access_token: String,
    #[wire(sensitive)]
    pub refresh_token: Option<String>,
    pub token_type: String,
    #[wire(volatile)]
    pub issued_at: u64,
}

This is the tier to reach for. A class declared on the model rides with the model: wherever that type is encoded into a request body, decoded from a response, carried in a query proxy, or sent as a WebSocket message, the declaration travels with it. It needs no test configuration, cannot go stale relative to the field it describes, and is right for every test that ever touches the endpoint. Redaction is the chapter on the marks themselves — the placeholder forms, sensitive(mask | redacted | redacted_fn), and the Redact transform the derive emits.

For a response body the declaration reaches the recorder through the endpoint’s decoder. A response head carries no wire model, and by the time one arrives the endpoint that knew how to read it has been consumed — so the decode recipe rides out with the request, and the response is matched to it by status class:

impl DecodeBodyFn for PostToken {
    fn decode_fn() -> BodyFnDecoder<Self> { ParcelResponse::decode }
    fn response_specs(config: &ParcelConfig) -> Option<ResponseSpecs> {
        Some(ResponseSpecs::wire::<Oauth2Token, ParcelErrorWire>(config.json_lib().erase()))
    }
}

The success model and the error envelope are separate declarations because they are separate models — and because error bodies routinely echo request parameters back, secrets included; ok_only and error_only declare one side when the other has no body worth describing. A response that does not decode under the recipe its own decoder declared is a hard recording error: nothing is persisted, because nothing can be shown safe to persist.

Streams are declared per event, because a stream is not one document. The shape depends on the event name beside it, so the description rides the request head under the type belonging to the crate that parses the framing, and with_error_envelope pairs the two into the StreamSpecs a streaming decoder returns:

impl DecodeSse for WatchShipment {
    type Item = ShipmentDelta;
    fn response_specs(config: &ParcelConfig) -> Option<StreamSpecs> {
        let codec = config.json_lib().erase();
        Some(
            EventStreamSpec::sse::<ShipmentDelta>(codec)
                .with_sentinels(&["[DONE]"])
                .with_error_envelope::<ParcelErrorWire>(codec),
        )
    }
}

What no spec describes is framing: a body that is a run of length-prefixed messages, as a gRPC call’s is, has no whole-body value and is kept exactly as it arrived, to a .bin sidecar, compared byte for byte per message.

2. By name, on the test config

Some surfaces no model describes: a header a scheme adds, a cache-busting query parameter, a provider-specific key in an extension map. One builder family covers them:

ReplayConfig::default()
    .classify_header("x-parcel-request-id", FixtureClass::Required)
    .classify_header("x-parcel-nonce", FixtureClass::Omitted)
    .classify_response_header("set-cookie", FixtureClass::Sensitive)
    .classify_query_param("X-Amz-Signature", FixtureClass::Sensitive)
    .classify_body_key("vendor_token", FixtureClass::Sensitive)
    .classify_oauth2_body_keys()                    // the OAuth2 request and response keys, in one call

A name rule can tighten what a model leaves Plain; it cannot loosen a #[wire(sensitive)] field back to exact comparison. Masking applies both channels and comparison unions their anchors, so a declared secret stays a secret whatever a test config says about its name. The one credential surface that needs a name rule is a presigned URL: its signature and token parameters are minted outside the pipeline and never traverse the seal, so nothing stamps them, and the suites that exercise them class those names — X-Amz-Signature, X-Amz-Security-Token — with classify_query_param.

3. By value

A secret that leaks across surfaces — a tenant id that appears in a URL path, a header, and a body — is registered once by value, with the placeholder the fixtures will hold:

ReplayConfig::default()
    .with_sensitive_env_prefix("PARCEL_")
    .with_sensitive_file("tests/sensitive.local.env")    // gitignored; live values only
    .with_sensitive("api_key", "pk-DUMMY_KEY")
    .with_sensitive("tenant_id", "00000000-0000-4000-8000-000000000001")

The live value is resolved when recording — PARCEL_TENANT_ID in the environment, then the dotenv-style file — and replaced by the placeholder everywhere it appears, in both directions. In replay, api.sensitive("api_key") hands back the placeholder, so one test source drives both modes.

The registry can also learn. Masking is the one moment that knows both that a value is secret and what it is, and with learn_secrets(true) every live string structural masking replaces joins the run’s substitution list and is rewritten wherever else it appears — a secret echoed in an unmodelled response field, spliced into a URL path, repeated in a later request. It is off by default because learning is a content match, and it bets that no secret is also a substring of something innocent; a one-character token or a numeric id loses that bet by rewriting every fixture that happens to contain those bytes. Turn it on for a suite whose secrets are long and opaque and whose services echo them where no model declares them.

4. The twin

The three tiers above mask a request after it is encoded. The fourth records it redacted before it is encoded: api.query_redacted(endpoint) dispatches the live request as usual, but the fixture the recorder writes — and the request replay compares — is a twin built from a redacted clone of the endpoint, finalized under RequestOverrides::redact_credentials so the seal places placeholders where every credential and signature would go. Nothing in the twin ever held a live value, so nothing has to be found. It asks the endpoint to be Clone and ApiRedact, which a WireModel endpoint is (Redaction), and it is the only route for a secret inside a gRPC frame, where nothing can be masked in place. A FileBlob marked #[wire(sensitive)] becomes, under the twin, an empty blob with its MIME type kept and its file name placeholdered — a filesystem path or a browser handle belongs in no fixture, the part’s content type is structure rather than payload, and the file name is often the most identifying thing about an upload.

What fills itself in

Two of those tiers keep working when nobody remembered them.

The pipeline records what it placed. An endpoint declares that it needs auth; the header or query name a credential lands in is chosen later, by the scheme or a placement directive. The seal records every header and query parameter it placed from the store, and every header the request’s signer declares as its outputs(), so the recorder classes them Sensitive with no rule to remember — a custom scheme’s x-api-key, a signing scheme’s signature header — and replay compares them by presence for the same reason. A HeaderValue the pipeline flagged (is_sensitive()) masks whether or not anything named it. An explicit config rule is the deliberate override, for the test that means to record one verbatim.

The seal’s shape is the twin’s. redact_credentials keeps exactly the placements a live send would write and substitutes the placeholder, so the twin needs no list of credential names either.

Failure is loud, and it happens before dispatch

Request fixtures are written before the request is sent. A body that will not decode under its own declared model, a placeholder that will not parse into its field’s lane, a masking step that cannot be applied — any of these aborts the recording before a request whose secrets cannot be safely persisted goes out, and leaves no half-written fixture behind. The one exception is a client-streaming gRPC body under StreamingCapture::Spooled: the send is the read, so that request fixture is written when the body ends, with the sidecar recording how it ended.

Response masking fails the same way, minus the abort it is too late for: nothing is persisted, the error names what failed, and the test fails. A silently missing fixture is recoverable; a silently unmasked one is not, which is why the framework refuses to guess.

The refusals extend to the asks it cannot meet. A whole-body model cannot be recorded as a timing capture if masking would rewrite it, because the recorded chunk boundaries would not be the ones the network produced — declare an event stream instead, and masking works at event boundaries where re-cutting is honest. A stream event whose payload is neither a declared protocol marker nor decodable under its model stops the recording. A body-key class over a gRPC body that crossed the wire is refused, and a registered value that appears in a gRPC body — a twin’s included — is refused too: a byte-level replacement would leave the frame prefix and the length-delimited field both describing the value it replaced, and a registered value inside a twin is one the twin did not redact. And a gRPC reply is recorded exactly as the service sent it — a service that mints a credential in a frame writes it into the fixture in the clear, and the declaration to make is on the reply type.

Every fixture says what its writer enforced

Committed fixtures carry an attestation in the .meta sidecar beside them:

capi-meta 1
request.date=2026-01-01T00:00:00Z
request.masking=masked
response.date=2026-01-01T00:00:00.150Z
response.masking=masked

masked means the file was written under fail-closed masking; a body with no sensitive field still earns it, because the claim is that anything sensitive would have been masked. unmasked means enforcement was off or degraded — an unmasked diagnostic capture, or a best-effort pass that hit a warning — and the recorder writes it, knowing which occurred. synthetic means a person wrote the file, with sensitive fields deliberately holding valid-looking made-up data. Each key speaks for one file: request.masking and response.masking for the two sides, ws.masking for the .ws session belonging to them, and artifact.masking for an OAuth2 callback capture, which is numbered on a counter of its own and carries a sidecar of its own. That one most needs the gate: it holds an authorization code, and it is a .txt.

The gate is positive, and the suite runs it itself as one more native test — FixtureAudit::scan("tests/data")?.check()?, as the previous chapter shows. masked or synthetic passes; unmasked fails; an absent attestation fails too, because unknown provenance is exactly the case worth reading rather than assuming safe; and a file whose sidecar carries a capture= line is a working artifact — a diagnostic capture, a dev trace, or a marker this framework predates — and is refused outright, whatever its masking says.

cargo capi fixtures is the same audit from the command line, and two flags manage the one claim a machine cannot make. --stamp synthetic writes that disposition into every sidecar that attests none — a claim made on your behalf, for a corpus you know never held a real secret — and --list-synthetic prints the attested set, so an audit reads a short list of claims instead of grepping a corpus for plausible-looking tokens. The marker is an attestation, not proof: nothing offline can re-verify masking without the runtime specs that drove it, which is why synthetic is the disposition held to review.

When nothing describes the payload yet

Automated masking will meet a response it cannot handle on first contact. That is what the declarations are for — and you cannot write them for a shape you have not seen. The sanctioned path is two passes, and the second is automated:

1. Capture once.

let client = ReplayClient::diagnostic(ParcelReplay, "tests/data", "onboard_shipments");
// …or `.unmasked()` when the point is to see exactly what crossed the wire

A diagnostic capture always records, and never drains: the application reads the live stream while a tee records what it pulled and when, so stream timing — often the thing under diagnosis — survives. Masking is best-effort and never aborts the exchange being observed; an artifact that hit any warning is stamped unmasked. The artifact lands in diagnostic/<test_name>/ under the root with capture=diagnostic in its sidecar, so nothing mistakes it for a fixture.

2. Declare the classes the capture shows you need#[wire(sensitive)] on the models, classify_* for the surfaces no model describes, registry entries for values that leak across several. This is the human step and the durable one: the declarations outlive the capture and shape every fixture recorded afterwards.

3. Remask.

CAPI_REMASK=1 cargo test onboard_shipments

The test runs unchanged. The real endpoints build the real requests, so the current declarations ride them — but every response comes from the capture rather than the network. Each exchange is masked under those declarations and promoted into the test’s fixture directory through the same fail-closed path a live recording uses. Step 3 is cheap to repeat, which is the point: a quota-priced or rate-limited API is called once, and every declare → check → declare more iteration runs against bytes already on disk. Delete the quarantined capture when the fixtures replay green; the gate refuses it until you do.

For the payload no declaration can describe — a secret that is positional, or buried inside a string no field boundary reaches — ReplayConfig::with_fixture_transform is the last resort: a closure over the decoded body, applied after the declared classes. A .ws session has the same escape hatch per lane, with_ws_outgoing_transform for the frames the client sends and with_ws_incoming_transform for the ones the server does — the lane a server-issued token arrives on — each reaching a frame only where the session declared a spec for it. All three are test-side only, deliberately: a transform that had to live in a client library would be a sign the model is wrong.

Two timings, both legitimate

A fixture drains each body before the application sees it, so its timestamps measure network arrival — the right timing for an artifact that will be replayed, and what makes fail-closed masking possible at all: the whole exchange is known before anything is written.

A diagnostic capture tees, so its timestamps measure application pull — end to end, backpressure included. Draining would destroy exactly the observation it exists to make. Neither is more correct; they answer different questions, which is why the purpose is named rather than inferred. A teed body flushes from Drop, which cannot return an error, so failures are collected and surfaced by client.finish() — call it when you want to know that every capture reached disk.

Where to put the declaration

When you are unsure which tier a thing belongs in, ask who knows:

  • The model knows a field is a credential. Declare it there, and every test benefits.
  • The test knows that a particular header is noise in this scenario. Classify it there.
  • The run knows the live value of a shared identifier. Register it by value.
  • The endpoint knows its own fields, and a request whose secrets ride the body — or a gRPC frame — is recorded as its twin.

Anything the pipeline chooses at runtime — a placed credential’s header name, a signer’s output — declares itself, because the code that chose it is the only code that could.

Next: Developing Against a Live API — the same machinery turned toward an application under development, where the fixtures are a trace rather than a test.

Developing Against a Live API

Everything in the two previous chapters serves a finished client library: fixtures recorded once, masked, committed, replayed strictly forever after. Developing an application against a live API with no sandbox is a different problem. The same flow is re-run all day, every run repeats every call it already made, and each of those calls costs quota, time, or a side effect on a real account. Dev mode turns the replay machinery toward that problem: one directory that replays what it already holds and records what it does not.

The trace

ReplayClient::dev(config, flow_name) runs a replay engine and a recording client side by side over one directory, .capi_dev/<flow_name>/ beside the application (or under CAPI_DEV_ROOT). Every request is offered to the engine first. A key match replays; a call the trace has never seen goes out live through the recorder and is appended. So an empty directory records everything, a complete one replays everything, and the usual case — a flow whose last few calls are new — pays the network only for the frontier. There is no construction-time choice between recording and replaying, and the live environment is touched once per step rather than once per iteration.

An application is generic over its client, so dev mode is a swap at the construction site, switched by the ambient CAPI_DEV without recompiling:

let api = if ReplayClient::dev_requested() {
    let client = ReplayClient::dev(ParcelDev, "onboard_customer");
    let mut api = ParcelApi::new_with_defaults(client.clone());
    api.set_base_context(client.context());
    api
} else {
    ParcelApi::new_with_defaults(ReqwestClient::default())
};

dev needs a live client on the same terms recording does — the bundled one under reqwest or native-client, the Web Fetch client on browser wasm — and dev_with_client(client, config, flow_name) takes one of your own on every lane, which is also how an application hands dev mode its production client, pipeline and all. dev_with_backend and dev_with_client_and_backend supply the fixture backend as well.

What follows from any call being able to go live

Three things, each a consequence of not knowing whether a request will hit or miss until it is offered to the trace.

The clock runs live throughout. Requests are signed above the client, before hit or miss is knowable, so a signature computed against mock time would be wrong for the calls that do go out. A replayed response is served with the real clock still running.

sensitive answers with live values, and resolves them eagerly, so a missing one fails before any traffic. The trace is matched in the form it was written, so replayed responses come back carrying the developer’s current live values, and refreshing a credential is a change to the environment rather than to any fixture.

A request that matches a recorded exchange but disagrees with it is a hard error, ReplayError::StaleFixture, not a re-recording. The recorded tail is evidence of effects already applied to the live environment, and re-recording from an arbitrary midpoint would replay the prefix and then issue the changed call against state still carrying the original’s effects — the corruption this mode exists to avoid. The error says what the two ways out are: delete the stale pair (and, when in doubt, everything recorded after it) to force those calls live on the next run, or hand-edit the pair, which the textual fixture format is for.

A trace is not a fixture set

It holds the server’s real values on purpose. A placeholder replayed into the live tail would be sent straight back to the API, so every artifact is stamped unmasked, marked capture=dev, kept out of tests/, and refused by the commit gate as it stands. Add .capi_dev/ to the project’s .gitignore.

Two config knobs shape what the trace keeps. route_always_live("/oauth2/token") exchanges every request under a path live on every run — never replayed, never recorded, never consumed from the trace — for the exchanges that must happen fresh: a session mint, a short-lived credential endpoint whose recorded mint would otherwise be served, byte-identical request and all, long after the token expired. route_always_live_method(Method::POST, "/orders") narrows it to one method, for a path whose reads may be cached but whose writes may not. And with_record_server_errors(true) keeps a response the server failed on; off by default, because a recorded 5xx would replay forever and one transient hiccup would wedge the trace until files were deleted by hand. Turn it on to develop against the error itself.

client.mock_only() on a dev client drops the recorder mid-flow: what the trace holds still replays, and anything it does not now fails instead of going live. client.finish() flushes pending record-time state and logs the run summary — replayed, recorded, always-live, stale, and unconsumed counts — where you call it rather than at an arbitrary later drop.

From trace to fixtures

A finished trace is a real record of a flow that was actually exercised, and it holds the server’s real values — exactly what makes it unfit to commit and perfect to remask. remask_from is the exit ramp:

CAPI_REMASK=1 CAPI_REMASK_FROM=.capi_dev/onboard_customer cargo test onboard_customer

The test runs unchanged. Its real endpoints build the real requests, so the test’s own declarations ride them, and every response comes from the trace rather than the network; each exchange is masked under those declarations and promoted into the test’s fixture directory through the same fail-closed path a live recording uses. One call to the API produced both the application’s trace and the test’s fixtures. ReplayClient::for_test consults both switches, so the promotion needs no harness change; remask_from(config, source, dir, name) is the programmatic form.

Live experiments as tests

Sometimes the experiment belongs in the test suite — a probe against the live service that must never run by accident. #[capi_test(development)] marks one: the test is #[ignore]d out of an ordinary cargo test (which compiles, lists, and reports it as ignored, development: …), and runs deliberately with -- --ignored or -- --include-ignored. #[development] on a module marks every #[capi_test] inside it.

The marker and the harness hold each other honest. ReplayClient::dev_for_test is dev for a test, and it refuses to construct outside a development body: a live experiment whose author forgot the marker — and which an ordinary cargo test would therefore run — panics with the fix in the message, before any network traffic, instead of quietly going live. Applications keep using dev; the handshake is a test-only contract.

Seeing the exchange

Two tools show what crossed the wire when a fixture or a trace is not what you want. ReplayClient::diagnostic(config, dir, name) captures an exchange for inspection rather than replay — always recording, never draining, so stream timing survives — into diagnostic/<name>/ under the root, and .unmasked() turns masking off for it; this is pass one of the onboarding workflow. And capi_debug_dump’s DebugClient wraps any client and writes every exchange that passes through it — request head, response head, trailer block, as much of each body as asked — in the same canonical text format the fixtures use, unredacted; it is a development tool, and a dump is the secret it contains.

Next: Verifying a Repo — the gate that runs the lanes, lints the manifest, and audits the fixtures, in any repo built on the framework.

Verifying a Repo: cargo capi verify

A crate built on Capi compiles sync, async, and for browser wasm from one definition, which is exactly why a default-lane cargo test proves almost nothing about it. Whole modules sit behind async; the single-threaded lane changes every bound in the graph; the browser target picks a different platform half. A green default lane says nothing about any of them. capi_verify is the lane vocabulary that does, and cargo-capi is the command over it — the gate in any repo built on the framework, run from the repo you are working in:

cargo install cargo-capi
cargo capi verify
cargo capi verify --full

The gate and the full form

cargo capi verify is the push gate, cheap and linear in the number of features: the manifest lint; clippy in the fixed lanes — default, no-default, std,async, and the single-threaded lane where the crate declares it; the lane-matched test runs, sync and async; doctests; and the browser-wasm check (--all-targets and no-default) where the crate reaches it.

--full is the exhaustive form, for a nightly run or the commit before a release. It adds clippy and test feature powersets on the host and on browser wasm, the wasm test build, the bare-metal targets, loom, the docs.rs renders (async and sync), the whole-workspace check, the fixture gate, and formatting:

LaneInWhat it runs
manifest-lintgatethe static rules below, over cargo metadata and the toolchain pin
gategateper crate: host clippy in the fixed lanes, the browser-wasm cargo check, and the lane-matched test runs
doctestsgatecargo test --doc in the documented lane — defaults plus async and the matching harness lane
fmt--fullcargo fmt --all --check
fixtures--fullthe fixture attestation and format gate
clippy--fullper-crate cargo hack clippy --feature-powerset, host and browser wasm, plus the --all-targets passes for the default and async harness lanes
tests--fullper-crate --lib --tests in the sync and async lanes, each over a depth-2 powerset of the crate’s extra features; pinned-test-lanes add one plain run each
wasm-tests--fullcargo test --target wasm32-unknown-unknown --no-run — a compile check of the wasm test binaries
bare--fullclippy powersets on thumbv7em-none-eabihf and riscv32imac-unknown-none-elf with --cfg getrandom_backend="custom"; std/js and platform-bound features excluded
loom--fullmodel checks under --cfg capi_loom, in an isolated target/loom
docsrs--fullcargo doc --no-deps under the crate’s real docs.rs feature set with RUSTDOCFLAGS=-D warnings; a wasm doc build too when the docs.rs metadata lists the target
docsrs-sync--fullthe same render with the async axis off, for a crate whose docs.rs feature set names async — the sync half of a cfg(not(feature = "async")) twin is rendered nowhere else
workspace-check--fullcargo check --workspace --all-targets, host and browser wasm

--only and --skip select lanes by name, -p narrows the per-package lanes, and --list prints the plan without running it. Every task announces itself before it spawns, captures its output, and prints it only on failure — with a reproduce: line that runs the same command from a shell, environment included. --log writes one JSON record per task, and --resume <log> skips what a previous run proved green at the same commit, so an interrupted full run continues rather than restarts. The clippy, tests, and bare lanes need cargo-hack; the browser and bare lanes need their rustup targets.

Policy in the manifest

How a repo verifies is declared in the repo, the way [package.metadata.docs.rs] already is: [workspace.metadata.capi] for repo-wide settings and [package.metadata.capi] on the package a setting names. A repo with a root package and no workspace table puts its repo-wide settings in that package’s table, and an empty table is a complete declaration — the defaults, browser wasm on and every lane, suit a dual-shape add-on or a client library:

[package.metadata.capi]
# The gRPC upload test drives the reqwest adapter as a second transport, and
# its grpc lane implies its async lane, so the async test lane names it.
extra-async-test-features = ["capic_reqwest/grpc"]

[package.metadata.docs.rs]
all-features = true

Repo-wide keys: browser (whether the packages build for browser wasm; default true), browser-only, bare (join the bare-metal lane), bare-self-configured, skip-lanes, workspace-check-features, aliases (extra cargo aliases for the generated block, as { name = "command" }), and wasm-incompatible-dev-deps (extends Rule B’s list). Per-package keys: browser = false (a native-only member), host-only (a build-time library outside the platform model), bare = false, powerset = false (pinned lanes instead of feature powersets), pinned-test-lanes, bare-pinned-lanes, platform-std-features, host-only-features, mutually-exclusive-features (as [["a", "b"]]), bare-pinned-features, bare-exclude-features, sync-docs-features, loom-tests, extra-async-test-features, and lint-allow.

Every key is validated against the metadata it describes: an unknown key, a key in the wrong position, or a feature or dependency name the package does not have is an error, never a silently ignored string.

all-features = true is the docs.rs convention: features are additive per target, so the full surface renders in one pass, and the docsrs lane renders under exactly that set. The sync render has nothing to subtract from, so a framework crate that documents with all-features and declares async names its sync doc feature set in sync-docs-features; a client crate, whose async only forwards, is skipped there at no cost in coverage.

The manifest lint

The platform model makes a handful of manifest mistakes easy, and each one surfaces far from its cause — as an unresolved import three crates away, or a lane that cannot compile at all. manifest-lint catches them statically:

  • A — no [features] entry may forward into a dev-only dependency. Cargo propagates the feature name but not the optional-dependency activation it gates, so the failure is a far-away unresolved import.
  • B — a dev-dependency that cannot compile for browser wasm (a curated default list, extended with wasm-incompatible-dev-deps) must live in a dev-dependency table whose target excludes browser wasm, whenever the crate builds for it.
  • D — forward symmetry over the feature axes: a crate declaring std, async, or single-threaded must forward it into every path dependency that declares the same axis. A crate with JS host bindings as browser-wasm target-table optionals routes them through a local js feature included in its std.
  • E — no retired wasm-js feature may exist. Not allowlistable.
  • F — the async and single-threaded axes must close over the entire family dependency chain, lane-selected dev harnesses included. Not allowlistable, because the resulting lane cannot compile.
  • I — every package declares rust-version.
  • J — the repo’s rust-toolchain.toml pins exactly the highest rust-version its packages declare, so the declared floor is what compiles them. A floating channel and a missing file are both violations.

An intentional exception to A, B, or D goes in the package’s own table, as lint-allow = ["rule_a:<feature>:<dep>", "rule_b:<dev-dep>", "rule_d:<dep>:<axis>", "rule_d:js"]. An entry that suppresses nothing is an error, so the list cannot go stale.

The alias block

The async axis reshapes traits graph-wide and cannot be forwarded into a dev-only test harness from a lib feature, so the harness lane has to be named at the command line — cargo test --features async,capi_test_framework/async — and that is the spelling nobody should retype. cargo capi aliases derives it from the manifest and writes it into the repo’s committed .cargo/config.toml:

# >>> generated by `cargo capi aliases` — do not edit
[alias]
test-sync = "test"
test-async = "test --features async,capi_test_framework/async,capic_reqwest/async,capic_reqwest/grpc"
check-wasm = "check --target wasm32-unknown-unknown --all-targets"
# <<< end generated

Only the fenced block belongs to the writer; durable per-repo cargo settings live outside the fences in the same file and survive regeneration. --check fails when the committed block has drifted from what the metadata implies, and a full-scope verify runs it. No [patch.crates-io] table lives here, deliberately: a patch entry names a filesystem path, and this file is committed — the table that redirects family crates at local checkouts is cargo capi patch’s, written at a parent directory and inside no repo.

The fixture gate

cargo capi fixtures is the commit gate on recorded test data: it walks every tests/data directory in the repo, reads what each fixture attests, and refuses an unmasked or unattested file, a file in a shape the format gate does not accept, and any working artifact carrying a capture= marker — the attestation model in one command. --stamp synthetic writes that disposition into every sidecar that attests none, a claim made on your behalf for a corpus you know never held a real secret; --list-synthetic prints the attested set for review. The fixtures lane of a full verify runs the same check, and a suite runs it as one of its own tests through FixtureAudit.

What “done” means

Per-crate lanes while iterating, cargo capi verify before a push, --full before a merge or a release — and, whichever you ran, name it when you report. A repo’s CI calls the same command, so local verification and the pipeline agree on what green means; and because the framework’s own crates verify with the same tool, a client library’s gate is the one the framework’s authors run.

This closes the Testing part — and the journey from an empty project to a verified, shippable client. What follows is reference material: the internals of one query, the full error model, the design rationale, and the appendix.

Under the Hood: Transport, Context, Time, Locking, Notification, Firewall

The Five Ideas and the Request Lifecycle gave the lifecycle as a mental model. This chapter is the mechanical trace — the exact order of operations for one query, and the runtime primitives underneath — for contributors and for anyone debugging deep. Nothing here is needed to use the framework; it is the machine room.

The full trace

api.query(endpoint) unfolds as follows.

1. The scoped request converts the input. Every query entry point accepts an IntoEndpoint; a plain endpoint converts to itself infallibly, a derived input runs its own conversion, and a failure is RequestError::Convert. The scoped request then hands the endpoint and its RequestOverrides to the seated runnerDirectRunner unless a verb seated another — through a RunnerAccess, the borrowed door. Everything below happens inside RunnerAccess::exchange, once per dispatch.

2. ApiRequest::prepare(config, endpoint) derives the context and reads the endpoint. The context is config.base_context().prepare(): same clock, a forked RNG stream, a fresh ContextId, an empty extensions map, a child notification sender. A follow-up uses prepare_with_context(parent) and prepare_follow_up() instead. Assembly then gathers every &self-taking value in one pass, each receiving (config, &context):

  • auth() — the credential declaration, resolved into AuthSettings (one requirement per credential: the key to look up, whether a miss is tolerated, where it rides); Auth::NONE attaches nothing;
  • query_string(config, context) — one call yields the query text and its decode recipe, so the spec riding the request can never describe bytes other than the ones it travels with;
  • url(config, context).to_url(), headers(config, context), accept(config, context);
  • rate_limit(config, context).to_rate_limiter() and signer(config, context).to_signer() — the limiter and the service signer, resolved now and carried as typed fields of the plan.

Then, while the endpoint is still whole, <Endpt::Decoder>::state(&endpoint) snapshots the decoder’s State, and method() and content_type(config, context) are read.

3. The head is assembled. A non-empty config user_agent is inserted — it replaces any User-Agent the endpoint declared, and an endpoint-declared one survives only when the config supplies none; Accept is set; Host is removed — an end-to-end header the client derives from the URL; and the URL is built with the query string. The auth and query specs are recorded on the head’s RequestSpecs.

4. The two preparation hooks run, in a fixed order and against the head the members will see: the required capability’s prepare_request(head, ctx) first — protocol mechanics, the WebSocket upgrade headers — then the decoder’s prepare_request(endpoint, config, ctx, head), which records the ResponseSpecs the fixture tooling reads and any header the decode contract implies. Both run before middleware, signing, or recording observe the head, so everything they write is part of the signed and recorded request.

5. body(self, config, context) consumes the endpoint. Its ToBody becomes a RequestBody; a PolicySigned body is signed here against the config’s store. The body’s own content type wins over the endpoint’s (a multipart body knows its boundary); the body spec and its runtime key classes are recorded; body_source is derived from the stream itself — Empty, Buffered, or Streaming — never hand-declared; and Content-Length is set only when the body knows its exact length, so a bodiless request stays bare. The result is a Request (head plus Bytestream), the decoder state, and the RequestPlanRequestPlan::resolved(rate_limiter, signing, cosigner, auth_store): everything this exchange is gated, sealed, shaped, and metered by, with the two signing layers held apart so a per-send cosigner swap cannot disturb the endpoint’s service signature.

6. The overrides apply, once. apply_overrides patches the head, the plan, and the context with the caller’s RequestOverrides — headers, a substituted limiter, with_auth/as_role, the pipeline delta, transfer stages — and stores them on the context as a lane-typed extension, the one place the lineage reads them from: every follow-up issued under this request applies them freshly at its own assembly, and a credential exchange forwards the transport half and drops the auth half.

7. send(client) runs one traversal. ApiRequest::send opens the client door, mints the detached FollowUpChannel for the endpoint’s lane, and calls traverse with Entry::Primary. The traversal merges the config’s pipeline with the plan’s delta and runs the eight steps: the four request phases, the gate (the plan’s limiter), the seal (resolution and placement from the plan’s store, then the signer with the cosigner folded over it), transmit (hop-by-hop validation, the Host strip again, the capability send, the transfer stages), and the unwind. The response envelope splits at the adapter’s return: the Response unwinds through the members, the extension is held aside. On the final attempt’s response, before any member transforms it, the reactive limiter is fed the head.

8. ApiResponse bundles the reply: the response head and body, the typed extension, the config snapshot, the detached channel, the request’s context, and the decoder State from step 2. ResponseParts is that bundle taken apart.

9. body_decoded() runs the decoder<Endpt::Decoder>::decode(rsp, extension, config, context, client, state), with the RunnerAccess as the door — turning bytes into the Output through a Codec<Format> or laddering to an error. The runner returns the output, the scoped request maps it through the input’s MapOutput, and the query resolves.

capi_transport

The transport crate owns the spine: ApiRequest (assembly, overrides, send), ApiResponse and ResponseParts, ScopedRequest — the per-call override handle api.scoped() opens, onto which the runner verbs and the budget knobs are installed — and the Runner/RunnerAccess/Dispatcher vocabulary. It owns the Traversal with its three entries (new, credential_exchange, capture), the seal, and the three ways back in: FollowUp (the scope a door opens), Finalize with PackagedRequest and PackagedResponse (the capture surface), and PrepareInContext. RequestOverrides and RequestPlan are capi_core’s and re-exported here. The capability plane chapter covers the doors and the capture surface as a user would meet them.

capi_middleware

The member-facing half of the traversal vocabulary: Pipeline, the four Phases, the three member traits with their RequestFlow/ResponseFlow verbs, MemberId, MwError, LaneId, Transmit, TransportError, and the Body view a member shapes a body’s identity and dressing through. Members are non-blocking, bounded, and plan-time; a Resend resumes the request walk at the resending member’s own seat, which is why a member that must act on every wire exchange seats after a re-sender and one that must act once per operation seats before it. capi_transport owns the traversal that drives them.

capi_http_types

The bottom-of-graph vocabulary every transport compiles against: Method, StatusCode, Version, the header types and constants, RequestHead / ResponseHead / Request / Response, TrailerCell / TrailerState, the Invalid* errors, and validate. A request target is a url::Url, re-exported; the builder that applies the framework’s construction rules is capi_url, which this crate does not depend on — a transport reads an address and never builds one. Framework metadata about a request is not here: it rides the head’s Extensions, one entry per type, owned by the crate that reads it — capi_core::specs::RequestSpecs (the auth settings, the body and query specs, the response recipe, body_source), capi_authentication‘s credential-placement record, capi_base_decoders’ event-stream framing, a WebSocket session’s message schema. Adding a fact never touches the vocabulary. The text module, behind text-format, is the canonical head rendering the fixtures and capi_debug_dump share.

capi_context

ApiContext is the execution state threaded through the whole pipeline: an ApiClock, an ApiRng, an optional ContextId, an optional notification sender, a typed extensions map, and the request’s budget when one is set — an absolute deadline() minted by the context’s own clock, a per-wait stall() bound, and a cancel_token(). The budget is read by whatever enforces it, which is the adapter performing the exchange; an adapter that cannot enforce a knob refuses the send with a typed Refusal rather than dropping it silently. A token set on a config’s base context binds every request derived from it; scoped().with_cancel(token) is the per-request form.

Propertyprepare() (consumes)prepare_follow_up() (borrows)
Clockinheritedinherited
RNGforkedforked
ContextIdfreshfresh
Extensionsfresh, emptyshared with the parent
Notificationschild sender, bubbling to the baseinherited lineage
Budgetcarried acrosscarried across — the deadline keeps binding the pages, cancelling the parent reaches them

ApiRng is a facade over a pluggable RngProvider. A ContextId is drawn from it and nothing else: a context whose source cannot answer carries no ID rather than a derived stand-in, because a value that only looks random is worse than one that is absent. Injecting a clock and RNG is what makes replay tests and PKCE deterministic.

capi_time and capi_lock

Two small primitive crates absorb the platform difference:

  • capi_timeInstant for elapsed time and deadlines, SystemTime for epoch timestamps, and ApiClock, a clonable facade over a ClockProvider with now and sleep. The backend is compile-time: the standard library with futures-timer under std on native and WASI; the JS host clocks and gloo timers under js on browser wasm (included by std, the platform switch there); embassy-time in embedded builds; and a stub — monotonic time fixed at zero, no wall clock, no meaningful sleep — when no backend is selected.
  • capi_lockSyncMutex, SyncRwLock, SharedCell, SyncCondvar (under std), and AsyncMutex / AsyncRwLock (under async): parking_lot on native std, spin everywhere else including browser wasm, async-lock for the async family. No lock poisoning by design, and a spin backend means contended acquisition burns CPU — keep critical sections short.

capi_notification, capi_firewall, capi_debug_dump

  • capi_notification — a typed broadcast wired into ApiContext. A NotificationSender (attached with with_notifications) fans structured events — a Notification wrapping a NotificationKind, the built-in LogNotification, the transport’s TraversalEvent, a resumable stream’s ReconnectNotification, a transfer stage’s ProgressNotification — to every NotificationReceiver. Delivery is non-blocking, disconnected receivers are pruned, and ctx.notify(..) with no sender attached costs nothing. Senders propagate through prepare / prepare_follow_up, so a base context’s children all publish into one stream.
  • capi_firewall — the opt-in egress allow-list: Firewall::new(client, policy) wraps any client and checks an EgressPolicy against each send’s URL before delegating, opaque to everything above it and forwarding every lane of the client it wraps. The capability plane chapter has the policy, the modes, and what the firewall does not see.
  • capi_debug_dump — the same wrapper shape for development: DebugClient writes every exchange that passes through it in the canonical text format, unredacted.

The error model in full is next — the exhaustive reference behind the error-modeling tutorial.

The Error Model in Full

Modeling Your Domain Error taught you to model a domain error and read it from a caller. This is the exhaustive reference behind it: every type in capi_core::error and the three that feed it from below. You model one slot — Api — and the framework owns everything else in the taxonomy.

The top: FrameworkError and QueryError

The complete internal error is generic over the client’s error and your domain error, one arm per stage of a query:

pub enum FrameworkError<ClientErr: StdError, ApiErr: StdError> {
    Authorization(AuthError),                  // the seal could not produce a credential
    Request(RequestError),                     // on the way down: building the request
    Transport(TransportError<ClientErr>),      // the send produced no response
    Response(ResponseError<ApiErr>),           // on the way up: reading the response
}

Callers never handle those two generics. query() returns a QueryError: the same enum with both slots erased to a boxed StdError — anyhow-backed, so each stays downcastable — plus one optional slot of its own:

pub struct QueryError {
    error: FrameworkError<BoxStdError, BoxStdError>,
    head: Option<Box<ResponseHead>>,           // the response this failure was observed against
}

The head is attached by whoever observed a response and chose to keep it — the head-capturing runner does, on its error arm — so a caller reads a Retry-After, a rate-limit block, or a trailing metadata block off a failure the way it would off a success. Nothing attaches one by default, and a failure with no response to observe has none.

AuthError is capi_authentication’s boxed error, reached as capi_core::authorization::AuthError: a credential that could not be minted, renewed, or placed. TransportError is capi_middleware’s, because the traversal reports it; SendError and Refusal, the outcomes an adapter reports, are capi_context’s, because the budget an adapter enforces lives on the context. Every one of them lands in this enum without changing shape.

RequestError — building the request

Failures on the way down, before a byte leaves:

VariantFrom
Convert(ConvertError)an IntoEndpoint conversion — recovered typed by convert_error::<E>()
InvalidUrl(UrlError)a URL that could not be built
Header(HttpHeaderError)a header that failed validation
Params(ParamsError)encoding the query string
Body(BodyError)constructing or encoding the request body
Backoffthe backoff loop of a retrying send could not initialize or run
Io(std::io::Error)I/O during preparation — std on native and WASI only
Rngthe random source could not seed
Infallible(Infallible)the From impl that lets an infallible conversion use ?

Every arm maps to ErrorKind::InvalidRequest. Nothing was sent, and the same request fails the same way again.

TransportError<ClientErr> — the send produced no response

An adapter’s send answers with SendError<Client::Error>, and this is what that becomes one layer up, arm for arm — nothing folded together, so a caller reads the outcome without knowing which adapter was underneath:

VariantMeaningkind()
TimedOut {}the whole-call deadline passed before the response head arrivedTransport
Stalled {}one wait on the peer outlived the per-wait stall boundTransport
Cancelled {}the request’s cancel token firedCancelled
Refused { refusal }the client cannot enforce a knob the request carries; nothing was sentInvalidRequest
Client(ClientErr)the client failed on its own terms — DNS, TLS, a protocol violationTransport
Middleware(anyhow::Error)a pipeline member, the seal, or a transmit invariant refusedTransport

The first four are the framework’s own account of the send: an adapter reports them for what it observed and never spells them in its own error type. The enum is exhaustive, so a consumer mapping it is told when an arm joins, while those four arms are each #[non_exhaustive], so a fact can join one without a major version — build them with TransportError::timed_out() / stalled() / cancelled() / refused(r), and match them with ...

Refusal is one type shared by every adapter and wrapper, one variant per refused knob — Deadline, Stall, Cancel, each naming the refusing client — and #[non_exhaustive], since the vocabulary grows with the knobs clients can decline. It is the runtime backstop behind the compile-time markers: TimeoutCapable, StallCapable, and CancelCapable cover every request routed through a client, and a refusal fires only where a budget reaches an adapter by a path no marker guards — a channel-carried follow-up send, a hand-built context. A refusal is raised before any exchange, and retrying the same send against the same client refuses again; the fix is at the construction site the variant names.

ResponseError<ApiErr> — reading the response

Failures on the way up. This is the type your Endpoint::Error fills one slot of:

VariantShapeMeaning
Api{ error: ApiErr, annotations: Option<Box<ErrorAnnotations>> }your modeled domain error, with the signals captured at decode time
Bytestream(BytestreamError)the body stream itself failed
Decoding{ status: Option<StatusCode>, source }a body failed to parse; status when the decoder knew it, None on the From<CodecError> path and for a MapOutputError
UnexpectedStatus(StatusCodeError)a status the decoder had no reading for
Unrecognized(UnrecognizedError)an error status whose body is not your error shape

Api and Decoding are struct variants — construct them with the helpers, not tuple syntax: ResponseError::api(..), api_annotated(..), decoding(..), decoding_with_status(..), unexpected_status(..), unexpected_status_with_headers(..), unrecognized(status, body), and from_envelope_or_unrecognized(..), the status ladder’s rung. There is no serde variant: every codec failure routes through CodecError.

BytestreamError carries the same three budget outcomes the send path does — TimedOut, Stalled, Cancelled — for the case where the knob fired while the body was still arriving, and they classify identically on both paths, because where the transfer stopped says nothing about what stopped it. A body that stopped arriving (is_incomplete(): an unexpected EOF, a length mismatch, a truncated codec stream) is Transport and retryable, because another attempt may get all of it. A body that arrived and was wrong is Io — it will arrive wrong again.

pub enum StatusCodeError {
    Client(StatusCode),                                        // 4xx
    Redirection { status_code: StatusCode, location: String }, // 3xx, Location when present
    Server(StatusCode),                                        // 5xx
    Other(StatusCode),                                         // a 1xx or 2xx the decoder rejected
}

ErrorKind — the triage enum

QueryError::kind() collapses everything to one of twelve kinds, and the mapping is spelled out arm by arm rather than wildcarded, so a new arm has to be classified rather than silently reading as a network failure:

KindArms that land here
AuthenticationAuthorization(_)
InvalidRequestevery Request(_); Transport(Refused)
TransportTransport(TimedOut | Stalled | Client | Middleware); Response(Bytestream(TimedOut | Stalled)); an incomplete Bytestream
CancelledTransport(Cancelled); Response(Bytestream(Cancelled))
StatusClientUnexpectedStatus(Client); Unrecognized with a 4xx
StatusServerUnexpectedStatus(Server); Unrecognized with a 5xx; Decoding whose recorded status is a 5xx
StatusRedirectUnexpectedStatus(Redirection)
ApiResponse(Api)
DecodingDecoding with no status, or a non-5xx one
Ioany other Bytestream failure
OtherUnexpectedStatus(Other); Unrecognized with a 1xx/2xx/3xx
RetriesExhaustedreserved for a retry strategy that spent its attempts; the shipped retry runner hands back the last attempt’s own error instead, so no framework arm maps here today
#[repr(i32)]
pub enum ErrorKind {
    Authentication = 0, InvalidRequest = 1, Transport = 2, RetriesExhausted = 3,
    StatusClient = 4, StatusServer = 5, StatusRedirect = 6, Api = 7, Decoding = 8,
    Io = 9, Other = 10, Cancelled = 11,
}

The discriminants are written out and append-only, because a foreign-function binding mirrors them in its own header: a kind keeps the number it has and a new kind takes the next one. The enum stays exhaustive, so a binding that maps every kind is told by its compiler when one joins.

Retryability is two questions. ErrorKind::is_retryable() is true for Transport, StatusServer, and RetriesExhausted; RetryKind::is_retryable() is true for Transient and Throttling. QueryError::is_retryable() is the disjunction — the kind says so, or the decoder captured a RetryKind that does — so a modeled Api error the service marked throttling is retryable even though its kind is not. Cancelled is deliberately not Transport: the token stays fired, so no further attempt can succeed, and the retry runner stops on the first attempt instead of sleeping through a backoff schedule. A Refused send lands in InvalidRequest for the same reason.

The CodecError bridge

Wire-model failures cross into the error model through From<CodecError> impls, never a serde variant: CodecError → ResponseError::Decoding on the decode path (with no status — the ? in a decoder does not know one), and CodecError → BodyError / ParamsError → RequestError on the encode paths. The codec is the single seam between the wire model and the taxonomy.

Metadata and envelope types

The types that carry a domain error’s detail to callers who do not downcast:

  • FromErrorEnvelope<Env>from_envelope(Env) -> Self builds your E from a parsed envelope; from_status(u16) -> Option<Self> maps a bare status, defaulting to None. The ladder’s two rungs.
  • ProvideErrorMetadatacode(), message(), request_id(), and retry_kind(), each defaulting to None; implement the ones your API provides, and the capture site derives a status-based RetryKind when the body offers none.
  • ErrorMetadata{ code, message, request_id: Option<String> }, surfaced by QueryError::metadata().
  • ErrorAnnotations{ metadata: Option<ErrorMetadata>, retry: Option<RetryKind> }, the box the Api variant carries when the decoder opted in through from_envelope_or_unrecognized or api_annotated; empty otherwise, so the un-annotated path stays allocation-free.
  • UnrecognizedError{ status, body: Vec<u8> } with text(), preserving the raw error body when it is not your shape.
  • RetryKindTransient (5xx, request timeout: back off), Throttling (429: wait the delay), Terminal; RetryKind::from_status derives one from a code.

The accessor family

From a QueryError, in order of how much you want. Every accessor borrows — Option<&E>, Option<&ResponseHead> — so a match arm can ask several questions of one value:

err.kind();                          // ErrorKind — the cheapest triage
err.is_retryable();                  // kind or captured RetryKind says so
err.retry_kind();                    // Option<RetryKind> — how, when the decoder captured it
err.is_api() / is_auth() / is_decode() / is_io() / is_transport() / is_invalid_request();
err.is_status_client() / is_status_server() / is_rate_limited();
err.is_timed_out();                  // the whole-call deadline passed, head or body
err.is_stalled();                    // one wait on the peer outlived the stall bound
err.is_cancelled();                  // the request's cancel token fired
err.refusal();                       // Option<&Refusal> — the knob the client declined
err.status();                        // Option<StatusCode> — unexpected, unrecognized, or a decoding failure that recorded one
err.metadata();                      // Option<&ErrorMetadata> — code, message, request id
err.response_head();                 // Option<&ResponseHead> — when a head-capturing runner kept it
err.api_error::<MyError>();          // Option<&MyError> — the typed domain error
err.client_error(&api);              // Option<&Client::Error> — the adapter's own, its type inferred from the interface
err.convert_error::<E>();            // Option<&E> — an IntoEndpoint conversion failure
err.map_output_error::<E>();         // Option<&E> — a MapOutput failure
err.downcast::<C, A>();              // the whole FrameworkError<C, A> back, by value
err.to_log_string();                 // the one-line form

status() is None for a modeled Api error: its status lives in the domain type, and metadata() / retry_kind() are the views onto it. The budget four are the only way to tell those outcomes from an ordinary network failure, since a timeout and a stall share ErrorKind::Transport with a dropped connection.

Practice: triage with kind() and the is_* predicates for control flow — retry, surface, ignore — read metadata() for logging, and downcast with api_error::<E>() only when you need the full domain type. That two-level design is why Modeling Your Domain Error has you model one envelope: the framework turns it into both the cheap view and the rich one.

Next: the design rationale — which of the framework’s rules are hard requirements and which are best practices.

Design Rationale: Hard Requirements vs Best Practices

The book has shown a lot of machinery — the wire model, the config onion, the credential flows, the derives, the pipeline. It is worth ending by separating what the framework requires from what it merely recommends, because the mandatory core is far smaller than the convenient shell built on top of it, and by recording the decisions that recur underneath both.

The hard requirements

Strip away every convenience and an endpoint is irreducibly five associated types and two methods:

impl Endpoint for GetShipment {
    type ApiConfig = ParcelConfig;   // the config it reads
    type Output = Shipment;          // what a query resolves to
    type Error = ParcelError;        // the domain error it fills ResponseError's Api slot with
    type Decoder = BodyDecoder;      // the marker naming its decode contract
    type Requires = Http;            // the lane a client must support

    fn method(&self) -> Method { Method::GET }
    fn url(&self, config: &ParcelConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path("/shipments/shp_123")
    }
}

That is the whole contract. Every other Endpoint method is a provided default you override only when it varies: headers, auth, accept, query_string, content_type, body, rate_limit, signer. The decoder marker resolves to a decode function over the response — Vec<u8> and a head for BodyDecoder, the raw response and the door for type Decoder = Self — and the body defaults to empty. The four hooks that can fail say so in their signatures: url returns a ToUrl whose to_url() is fallible, headers a Result<HeaderMap, _>, query_string a Result<impl ToQueryString, _>, and body a Result<impl ToBody, _>. Assembly surfaces each as a RequestError before anything is sent, so an endpoint author writes ? where a value can be wrong and nothing where it cannot.

The best practices, all opt-out

Layered on that core is a shell of conveniences, each with an escape hatch:

  • The wire model and its derives — or hand-write WireEncode / WireDecode, or skip codecs entirely and produce bytes yourself.
  • A swappable Codec<F> on the config — or name a codec inline, or use none.
  • The config onion and the credential flows — or implement ApiConfig and place credentials by hand.
  • The derives (WireModel, ApiEndpoint, capi, AuthScheme) — or write the impls and constructors yourself.
  • The replay harness — or test against a MockClient, or a client of your own.

The existence proof is the icecast_connect client: it speaks a codec-free binary protocol, stores no Codec at all — its XSPF playlists go through the quick-xml pull reader directly — and hand-rolls its byte handling, yet it is an ordinary Capi client using the same interface, auth, runner, and resumable-stream machinery as the JSON clients. The conveniences earn their place for the common case; they are never load-bearing.

Typed codecs as a design decision

The single most consequential wire-model choice is that a codec is type-tagged. A Codec<F> carries its format marker in a PhantomData<fn() -> F> on a Copy handle, so the marker lives in the handle’s phantom, not in the types that hold it. A config stores a Codec<Json> field; it is not generic over a format, and the endpoint and the interface never mention F.

That placement is what makes the design pay off, because the two obvious alternatives each force a bad trade. An untyped handle keeps the surface clean but lets an XML codec slip in where JSON was expected — a mistake caught only when the server rejects the request. A format type parameter threaded through ApiConfig, Endpoint, and every signature catches it at compile time but infects the whole API. The phantom on a Copy handle sidesteps both: Codec<Json> rejects Codec<Xml> at compile time and the API surface stays un-infected — and because Format is an unsealed, empty trait, a third party defines its own marker in one line. Codec<Unmarked> is the erased form, for the places (a decode recipe, a fixture reader) that carry a codec without a format in scope.

Codec-agnosticism as a design decision

Format behavior lives in the format contractcapiw_ext_json, capiw_ext_xml, capiw_ext_urlencoded, capiw_ext_protobuf — not in any concrete codec. A codec is a byte engine implementing the contract, and each codec carries its format’s conformance suite in its own tests, so two codecs for the same format agree on the wire. That is why the JSON engine is swappable without your types changing. The boundary is that “swappable” is not “mixable”: you choose one codec per format, decided once on the config, not two JSON codecs in one client.

Schemes are values, not typestate

Authentication is not encoded in the interface’s type. An interface holds a config whose store holds credentials by key, an endpoint declares which key it needs through Auth, and the seal places the credential at send time. The flow a client library exposes — api.authenticate(..), api.flow::<Token>(), a role’s tier — is a view onto that store, not a state the type system tracks. The trade is deliberate: a typestate would refuse an unauthenticated call at compile time, and it would also make every interface generic over its authentication state, every helper that holds one generic too, and a credential that arrives after construction (a refreshed token, a rotated key) a type change. A store keyed by AuthKey, with the seal resolving freshness at the moment of use, keeps the interface one type and puts the check where the credential actually is.

Durable rationale

Several other decisions recur and are worth recording:

  • No unsafe code of the family’s own. Every crate carries unsafe_code = "deny". That is for the family’s code: a third-party dependency may carry unsafe, and an optional feature that swaps in a faster backend with unsafe inside is welcome — more options for the developer cost nothing. For a framework handling credentials, a verifiable guarantee about its own code is worth more than any micro-optimization.
  • The orphan rule and thin wrappers. Rust forbids implementing a foreign trait on a foreign type, which is the reason for the crate count: each trait lives where you can implement it, and the capic_* / capiw_* wrappers are minimal glue bridging outside libraries (reqwest, serde_json) to the framework’s traits — not forks. Any library can implement the traits itself, the way many crates ship optional serde support; the family also ships a transport of its own, capic_native_client, on its sans-io protocol engines.
  • Re-implementing primitives. capi_lock, capi_time, capi_bytestream, and capi_http_types exist because the framework needs one stable interface across no_std, std-sync, std-async, and browser wasm, and few existing crates span all four. They are less battle-tested than their inspirations, which is part of why the release is pre-1.0.
  • Boxed futures at the erased seams. The runner plane (RunnerFuture), the query entry points (QueryFuture), the erased client send (ClientFuture), and the credential store’s object-safe backends — EntryRefresher and AuthSource, whose RefreshFuture and AuthSourceFuture aliases spell it — return Pin<Box<dyn Future + Send>> rather than async fn. Where a trait must be object-safe, the compiler cannot prove Send through a GAT projection in an async context, so a Tokio-movable future is hand-constructed and explicitly Send (rust-lang/rust#100013). Callers still .await; the Send bound drops away on the single-threaded lane and on browser wasm.
  • Additive features, target-selected platform. std switches surfaces on and never selects the platform; async flips trait shapes; single-threaded relaxes the bounds graph-wide. The compilation target picks the platform half, and the assert_feature_alignment! guard turns a mismatch anywhere in a graph into one compile error naming the crate (Features and Targets).

Standards the family holds itself to

The framework’s own crates follow rules that a client library inherits by example, and that are worth knowing when you read the source or extend it:

  • No panics. No unwrap, expect, or panic! in library code. Where a step seems to require one, the API is redesigned instead; where the stake is highest, a crate-root deny(clippy::unwrap_used, clippy::expect_used, clippy::panic) makes the rule checked rather than reviewed.
  • No blanket defensive programming. Speculative size caps, stream-buffer limits, and adversarial-header defenses are the right defaults for facing the unknown internet and the wrong ones here: a client is built for a known, well-defined service, and a speculative cap silently breaks legitimate traffic more often than it protects anyone. The same trust extends to the developer — whether an upload fits in memory is their judgement. What is guarded is genuine surprise: buffering a request stream of unknown size to retry it needs an explicit opt-in, because “retrying means holding the whole body” is exactly what a developer would not expect.
  • Credential handling prevents accidental leaks, not every leak. A carrier — a value handed to the framework so it can authenticate — is construct-then-consume: its secret is private, zeroized, and usually not Clone. A payload — a credential the service hands back — is the developer’s to use, so it stays public and readable, with a redacting Debug and #[wire(sensitive)] so a log line or a fixture never carries a working credential. Deciding per type, and saying why, beats zeroizing everything that smells secret.
  • Behavior attaches to a type. A public free function is discouraged wherever a reasonable receiver exists — a method surfaces in its type’s documentation and in completion, a free function only to someone who already knows its name, and the receiver often tightens the contract for free.
  • Comments describe the present. No before/after framing, no phase references, no review tickets; genuine “why” rationale and RFC references stay. The framework is unreleased, so nothing has a past worth naming.

Why v0.5.0

The framework is feature-complete enough to build real clients, but it is out for feedback before 1.0. The custom primitives need real-world vetting, and the areas most likely to shift — the request pipeline’s surface, the error taxonomy, the streaming decoder traits — are the ones this book leans on hardest. Building against it now means occasionally adjusting to a breaking change; it also means your feedback shapes what 1.0 becomes.

The appendix closes the book with the exhaustive reference — the crate inventory, the feature matrix, and the dependency graph.

Appendix: Crates, Features, Dependency Graph

The go-look-here reference, drawn from the family registry and the workspace manifests. For the narrative of how these fit together, see How the Crates Fit Together; this is the exhaustive index. The framework and its add-ons are at version 0.5, and every family dependency between repos is a plain version requirement resolved through the patch table until the family publishes.

The capi_rs framework workspace

The framework proper — contracts and their in-tree reference implementations, thirty-two members. All are #![no_std] + alloc except the proc-macro crates (capi_derive, capi_cookies_macro) and the macro-authoring kit they share (capi_macro_support), which are build-time only.

CrateJob / key exports
capi_rsThe facade — re-exports the members under short module aliases, plus prelude and reexport
capi_coreEndpoint, OverrideEndpoint, IntoEndpoint, ApiConfig, ApiInterface, ResponseDecoder and the body-decode traits, the error taxonomy, RequestSpecs, ApiRedact, presign
capi_protocolThe protocol-generic core: Protocol, Speaks, ApiClient, Duplex / DuplexError, StdError, the budget markers, the carrier module (Carrier / AsyncCarrier / PollDuplex / WakeCarrier, Dial / AsyncDial, DatagramCarrier / TimedDatagramCarrier / PollDatagram / DialDatagram), context re-exported
capip_httpThe HTTP protocol: Http / Grpc, Capability, Supports<Cap>, ResponseEnvelope / ExtensionOf, FollowUpChannel, ClientFuture
capi_transportTraversal and the seal; ApiRequest / ApiResponse / ScopedRequest; Runner / RunnerAccess / DirectRunner; FollowUp, Finalize, PackagedRequest, PackagedResponse
capi_middlewarePipeline, Phase, the three member traits, RequestFlow / ResponseFlow, Body, TransportError
capi_authenticationAuthStore / AuthEntry / AuthKey / AuthSettings, RequestSigner / OneShotSigner / Signing / NO_SIGNING / ToSigner, Cosigning, KeyMaterial custody, EntryRefresher / Freshness, presign_url / begin_policy / finish_policy
capi_rate_limitRateLimiter, WaitableRateLimiter, RateLimit, ToRateLimiter, LimiterUnit, NO_RATE_LIMIT
capi_limitersGcraLimiter, HeaderLimiter, MeteredLimiter, Rate, the provider presets
capi_base_configBaseConfig, CosignerCell, the auth config extensions
capi_base_interfaceBaseInterface, AuthScheme / AuthFlow / AuthEndpoint, Token / Basic and their flows, AuthTierInterface, Discoverable
capi_base_decodersBodyDecoder, PagedDecoder, SwitchDecoder, SseDecoder / EventDecoder, DocumentStreamDecoder, ChunkedDecoder, BytestreamDecoder, ResumableStreamDecoder / ResumeDownloadDecoder / StreamFnDecoder, FlatMapDecoder, ResponseBoundary
capi_base_encodersMultipart, Part, FileBlob
capi_extrasThe runners and their verbs (RetryRunner, RetryAfterRunner, RedirectRunner, RefreshRunner, WaitUntilRunner, WithHead, Raw, Wrapped), EndpointQuery, BackgroundRefresh / RefreshSchedule, the InjectRequestId and FollowRedirects members, the Progress / Throttle stages
capiw_wire_headersThe wire-model HTTP header encoder
capi_cookies (+ _macro)CookieJar, Cookie, CookieMiddleware, CookieConfigExt, CookieSessionSource, HasCookieJar, cookie_auth
capi_firewallFirewall<Client>, EgressPolicy, Mode
capi_debug_dumpDebugClient, the exchange dump in the canonical text format
capi_notificationNotificationSender / NotificationReceiver, Notification / NotificationKind, LogNotification
capi_contextApiContext, ApiRng, ContextId, CancelToken, SendError, Refusal
capi_bytestreamBytestream, ContentLength, BodySource, StreamCodec, MaybeSend / MaybeSync
capi_http_typesMethod, StatusCode, Version, HeaderMap / HeaderName / HeaderValue, Request / Response and their heads, TrailerCell / TrailerState, Extensions, the url::Url a head carries, the text format
capi_urlUrlBuilder, ToUrl, UrlError
capi_timeApiClock, Instant, SystemTime, sleep
capi_lockSyncMutex / SyncRwLock / SharedCell / SyncCondvar, AsyncMutex / AsyncRwLock
capi_feature_stateassert_feature_alignment!, assert_lane_alignment!, the feature-state constants
capi_deriveApiEndpoint, capi, AuthScheme
capi_macro_supportThe macro-authoring toolkit: lane primitives, diagnostics, auth templates
capi_test_coreMockClient / MockResponse / Fault, TestConfig / TestInterface, the mock clock and RNG, the pipeline test doubles

The other workspaces

WorkspaceMembers
capi_wirecapi_wire_model (Codec<F>, Format, CodecError, Binding, WireModel / WireEncode / WireDecode / WireShape / WireScalar, Redact), capi_wire_derive (the three wire derives), capi_wire_value (Value), capi_wire_datetime (Date / DateTime / Time / Timestamp), capi_wire_decimal (Decimal, FixedDecimal<SCALE>)
capi_authcapi_auth_oauth2 (ConfigOAuth2Grant, Oauth2Flow, TokenEndpoint, Oauth2Refresher, discovery, PKCE), capi_auth_jwt (ConfigJwtAuth, JwtBearerFlow, JwtRefresher, JwtBuilder / JwtClaims / JwtSigningKey)
capi_test_frameworkcapi_test_framework (ReplayClient, ReplayConfig, FixtureClass, FixtureAudit, QueryRedacted, the .ws session recorder), capi_test_macros (capi_test, development, strip_await)
capi_enginecapi_engine (the sans-io Conversation / Engine contract, the per-lane drivers, and the carrier traits re-exported from capi_protocol), capip_http1, capip_http2, capip_redis (the Redis marker, RedisConnect / RedisSession), the VOIP stack (capip_sip, capi_sdp, capip_stun, capip_rtp, capip_voip — the Sip and Media markers, SipEndpoint / MediaSession), and the engine-backed client capic_native_client (+ _interop)
capi_graphqlcapi_graphql (GraphqlCall, GraphqlApiConfig, Persisted, batching), capi_graphql_macros (graphql_schema! / graphql!), capi_graphql_schema (the host-only schema IR)

Single-crate repos

RepoKind
capic_reqwest / capic_ureq / capic_wasm_fetch / capic_reqwlessclient adapters — with capic_native_client above, the five in the capability table
capiw_serde_json / capiw_urlencoded / capiw_quick_xml / capiw_prostconcrete codecs, each carrying its format’s conformance suite
capiw_ext_json / capiw_ext_urlencoded (+ _macro) / capiw_ext_xml / capiw_ext_protobufformat contracts: the Json, UrlForm, Xml, and protobuf markers and their attribute vocabularies
capi_websocketthe WebSocket capability marker, RFC 6455 framing, TypedWsConnection, WsSession and the tunnel protocols, the split halves
capi_grpcunary, client-streaming, and server-streaming gRPC: Frame / FramedBody / MessageSource, GrpcDecoder / GrpcError / GrpcConfig, CompressionPolicy / MessageCoding, RequestMetadata / ResponseMetadata, GrpcStatus / GrpcCode
capi_html_formsFormFlow / FormFlowDecoder, HtmlForm, FormSelector, FieldUpdate / FormUpdate, HasUrlEncodedLib
capi_http_batchBatchConfig / ChangesetConfig, Batchable, BatchProfile, ArmedBatch, IntoBatch
capie_gzip / capie_miniz_oxide / capie_brotli / capie_aws_chunkedcontent-coding StreamCodecs: gzip, deflate, br, aws-chunked; the first two also drive gRPC per-message compression
capim_x402the x402 payment runner: X402 / X402Ext, RemoteAuthorizer, Terms / Requirement / Settlement
capi_auth_aws_sigv4 / capi_auth_azure / capi_auth_digestsigners: Sigv4Signer / AwsCredentials, SharedKeySigner / AzureKeyCredential, DigestSigner / DigestCredentials
capi_auth_googleGoogle service-account JWT and OAuth2 helpers
capiw_conformance_testscross-codec properties (private, unpublished)

Reference clients (*_capi_rs): aws_s3, azure_tables, generic_llm (a workspace over the OpenAI and Claude clients), google_places, google_routes, graphqlzero, grok, icecast_connect, paychex, ringcentral; the canaries grpc_test (tonic interop), nostd_canary (bare-metal QEMU), and runtime_canary (non-Tokio executors).

Tooling: capi_verify — a workspace of its own, the capi_verify planner library and the cargo-capi binary; capi-extension-template — the cargo generate template for a new codec, transport, auth, middleware, or client crate; and the private capi_test_suite — the registry, dev-config, and the family sweep.

The facade and prelude

capi_rs re-exports the Capi framework workspace under short module aliases — ::core, ::config, ::decoders, ::encoders, ::interface, ::protocol, ::transport, ::middleware, ::extras, ::authentication, ::rate_limiters, ::notification, ::lock, ::time, ::feature_state, ::http_headers, ::http_types, ::derive (behind derive), and ::cookies (behind cookies) — plus reexport for downstream forwarding. The protocol-generic core is reachable as capi_rs::protocol. The derives it surfaces are ApiEndpoint, capi, and AuthScheme, all behind derive. use capi_rs::prelude::*; brings the endpoint-authoring surface into scope — the core’s Protocol, Speaks, ApiClient, and Duplex among the names it carries; the seams that are meant to be unusual — FollowUp, Finalize, the capture types — ride no prelude and are imported from ::transport by name.

Crates outside that workspace are named directly by the client that uses them: the wire derives from capi_wire_model, Json from capiw_ext_json, UrlForm and urlform_extension from capiw_ext_urlencoded, the date and money carriers from capi_wire_datetime / capi_wire_decimal, and the capabilities and auth flows from capi_websocket, capi_grpc, capi_html_forms, capi_auth_oauth2, and capi_auth_jwt. capi_firewall and capi_debug_dump are members of the workspace but not of the facade: a wrapper is chosen by the application that seats the client, and depended on directly so that a look-alike cannot be substituted.

Feature matrix

capi_rsdefault = ["std", "derive"].

The three axes (the compilation target picks the platform; see Features and Targets):

FeatureEffect
std (default)standard-library support on native and WASI; on browser wasm the platform-capability switch that enables each crate’s js bindings
asyncthe async trait shapes; browser wasm always carries the async surface
single-threadeddrops Send / Sync graph-wide — additive to the build, non-additive to the API contract; implied by browser wasm

Add-ons: auth-basic, cookies, cookies-jar-access, derive (default). That is the whole list: the facade re-exports the framework workspace and nothing else, so the OAuth2 and JWT flows, WebSocket, gRPC, HTML forms, and the wire model are direct dependencies of the client that uses them, with their lanes forwarded from that client’s own features.

Adapter features that gate a lane: websocket on capic_reqwest, capic_ureq, capic_wasm_fetch, and capic_native_client; grpc on capic_reqwest (implying trailers, and therefore async) and capic_native_client; redis and voip on capic_native_client for the protocols it speaks off the pipeline. capic_reqwless is single-threaded by design and declares no lane beyond Http.

Harness features: capi_test_framework adds reqwest / native-client (the bundled live recorder, each exclusive with single-threaded), grpc, websocket, oauth2, serde-json / serde-form-urlencoded / quick-xml (structural body comparison), and js.

assert_feature_alignment! takes the tokens "async", "single-threaded", !"async", !"single-threaded", "native-only", and "browser-only"; assert_lane_alignment!("single-threaded") is the lane form. None of them is a Cargo feature, and std has no token.

Dependency graph

The edges are the manifests’ own — each workspace member’s family [dependencies], collapsed into layers:

graph TD
    client["client crate"] --> facade["capi_rs (facade)"]
    client --> protocols["capi_websocket / capi_grpc / capi_html_forms / capi_http_batch / capi_graphql"]
    client --> flows["capi_auth_oauth2 / capi_auth_jwt / signers"]
    client --> contracts["capiw_ext_json / capiw_ext_urlencoded / capiw_ext_xml / capiw_ext_protobuf"]
    facade --> interface["capi_base_interface"]
    facade --> extras["capi_extras"]
    facade --> config["capi_base_config / capi_limiters"]
    facade --> addons["capi_cookies / capiw_wire_headers"]
    facade --> codecs_in["capi_base_decoders / capi_base_encoders"]
    interface --> extras
    interface --> codecs_in
    extras --> codecs_in
    extras --> transport["capi_transport"]
    codecs_in --> transport
    config --> core["capi_core"]
    addons --> config
    addons --> core
    protocols --> core
    flows --> core
    transport --> core
    core --> planes["capip_http / capi_middleware / capi_authentication / capi_rate_limit / capi_url"]
    core --> wire["capi_wire_model (+ derive / value / datetime / decimal)"]
    contracts --> wire
    planes --> types["capi_http_types / capi_bytestream"]
    planes --> floor["capi_protocol (+ capi_context)"]
    types --> leaves["capi_notification / capi_time / capi_lock / capi_feature_state"]
    floor --> leaves
    wrappers["capi_firewall / capi_debug_dump"] --> planes
    codecs["capiw_serde_json / urlencoded / quick_xml / prost"] -.implements.-> contracts
    adapters["capic_reqwest / ureq / wasm_fetch / reqwless / native_client"] -.implements.-> planes
    harness["capi_test_framework"] --> facade
    harness --> testcore["capi_test_core"]
    testcore --> core

Three edges deserve a sentence. capi_base_config depends on capi_core and nothing above it — a config carries codecs and stores, and the decoders reach it through the endpoint, never the reverse. capi_base_decoders depends on capi_transport, because the paged, resumable, and flat-map decoders open the door and run traversals. And the wrappers depend only on capip_http and the HTTP vocabulary, which is what lets a firewall wrap any client the family or a third party ships.


That closes the book. From the decoupling thesis to here: you can build a Capi client, understand why it is shaped the way it is, and consume, patch, test, verify, and ship one everywhere.