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:
- Consumers use a client someone else shipped. You pick an HTTP client and a
codec (or take the defaults), authenticate, and call
query(). Your path: The Decoupling Thesis → Quick Start → Consuming and Patching a Client, dipping into Errors, Auth, and Clients, Lanes, and the Request Budget as needed. - Authors build or patch a client. Your path is the whole book, roughly in order: it mirrors how you actually build one — scaffold, config, auth, errors, decoders, types, endpoints, then the advanced and cross-cutting concerns.
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:
| Crate | What it proves |
|---|---|
grok_capi_rs | JSON, bearer + named-tier auth, SSE streaming, deferred polling, WebSocket, gRPC client streaming, multipart, pagination, resumable downloads |
google_places_capi_rs | API-key-in-header, OAuth2/JWT flows, typed header params (field masks) |
aws_s3_capi_rs | AWS SigV4 signing, presigned URLs, XML codec, raw and aws-chunked bodies, per-operation error enums |
icecast_connect_capi_rs | resumable/auto-reconnecting streams, a custom binary protocol, a codec-free client |
ringcentral_capi_rs | generic OAuth2 with a JWT-bearer grant as the default scheme |
paychex_capi_rs | a derived auth scheme that renews its own credential |
azure_tables_capi_rs | Azure SharedKey signing, OData batches and changesets |
graphqlzero_capi_rs | the 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
Endpointtrait. 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 noget()/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, andreqwlessfor embedded no_std, plus the family’s owncapic_native_clienton its sans-io protocol engines — or implementApiClientfor 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
asyncfeature switches the trait shapes, and the compilation target picks the platform — a browser build is--target wasm32-unknown-unknownwith 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’saws-chunkedframing 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_canaryreference 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/Syncbounds. No feature selects a platform, and the alignment guard fails a build whose crates disagree about theasyncorsingle-threadedaxis — 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 aureq(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 reqwest | Builder-style crate | Capi | |
|---|---|---|---|
| Sync and async from one definition | ✗ | ✗ | ✓ |
| wasm / embedded targets | ✗ | rarely | ✓ |
| Pluggable HTTP client | ✗ | ✗ | ✓ |
| Pluggable, type-checked wire codec | ✗ | ✗ | ✓ |
| Typed domain errors + framework-owned generic failures | you write it | you write it | ✓ |
| Streaming / pagination / WebSocket first-class | you write it | you write it | ✓ |
| Downstream can patch an endpoint without forking | ✗ | ✗ | ✓ |
| A credential store with seated renewal and request signers, shared by every endpoint | you write it | you write it | ✓ |
| A client that cannot perform an exchange is refused at compile time | ✗ | ✗ | ✓ |
| Replay tests from recorded, redacted fixtures | you write it | you 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]generatedGetShipment::new(shipment_id)— the field is neitherOption,bool, norVec, so it is a constructor parameter — andApiEndpointgeneratedpath_string(), which fills the template from the field markedlocation = "path".type Requires = Httpdeclares that the endpoint needs plain HTTP; a WebSocket endpoint would sayWebSocket, 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 isAuth::DEFAULT, a declaration that the request needs the config’s default credential, andBaseConfiginstalls no credential store — so the seal that places credentials would refuse the request before it is sent.Auth::NONEsays 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.BodyDecoderbuffers the body and hands it todecode_bodywhatever the status; here a malformed body or a404both surface as aDecodingerror, 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’sResponseErrorcarries the result either way. decode_from_sliceis the codec call. Namingcapiw_serde_json::codec()inline keeps the example to one file; a real client stores aCodec<Json>on its own config and reads it back throughconfig.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. SwapReqwestClientforcapic_ureq::UreqClientand the same endpoint runs blocking; the endpoint code does not change.api.query(...)is the one funnel. It returnsResult<Shipment, QueryError>;?bubbles up any failure the framework owns — transport, decode, an unexpected status — as aQueryError.
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
| Chapter | What you’ll learn |
|---|---|
| The Five Ideas and the Request Lifecycle | The mental model the rest of the book assumes |
| The Wire Model | What Codec<Json> and the wire derives are |
| Scaffolding a Client Crate | Turning this one file into a real crate with its own config |
| Consuming and Patching a Client | Using — 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
- 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.
- 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. - An endpoint is a struct + two macros + one
Endpointimpl + 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. - 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. - 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 aBytestreambody) and wraps the reply as anApiResponsefor decoding.ApiRequest::prepare()lives here; so does URL construction, header merging, and content-type negotiation. -
Client layer — executes the call.
ApiClientitself is only the identity of a client; the send lives onSpeaks<Proto>, one impl per protocol a client speaks, carrying that protocol’s error type and itscapi_send. For plain HTTP the send takes aRequestand hands back aResponse(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’sRequiresis matched against — is derived fromApiClient + Speaks<Http>by a blanket impl, and nobody writesimpl Supports. A client that cannot perform an exchange is refused at the query call site, at compile time — the capability plane.SendErroris 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 betweenBytestreamand the library’s own body type, and the family’s owncapic_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:
- You create the endpoint — a struct carrying this call’s data.
ApiRequest::prepare()builds the request. It reads the endpoint’s declarative methods to assemble aRequest— the URL, headers,Accept, and query string — then callsbody(self)last, the one method that consumes the endpoint. The exact ordering, and the finer mechanical details, are traced in Under the Hood.- The traversal runs — the request halves of the members of
Early,User,Mid, andLatein 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. - Transmit. The signer runs as the first act of every attempt, the transfer
stages meter or throttle the bytes, and the client’s
capi_sendfor the required capability performs the exchange. A response member mayResendthe exchange it holds (that is how redirects are followed), resuming at its own seat. - Unwind. The response halves of the same members run in reverse,
Lateback toEarly. ApiResponsewraps the reply with a config snapshot, the door (aFollowUpChannel, through which pagination and streaming issue follow-up sends), and theApiContext— everything the decoder needs.- Decoding rebuilds your type. The endpoint’s declared
Decoder, driven throughResponseDecoder, producesResult<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.
queryis the verb Ben Boeckel used in the article that seeded this framework, and it stuck. The HTTP spec has since grown an actualQUERYmethod, which makes the name look like it ought to sit beside aget()/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:
- 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 GetUseronly becauseEndpointis incapi_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, …). - 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. - Granular dependencies. A no_std client can depend on
capi_coreplus one adapter and skip the rest, keeping build times and binary size down. - The facade collapses it back.
capi_rsre-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 itscarriermodule) 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 facadecapi_rs; - the request pipeline —
capi_transport(the traversal engine and the seal),capi_middleware(the pipeline core: phases, members, verdicts),capip_http(theHttpmarker 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 cratescapi_http_types(heads, headers, trailers, the canonical text format) andcapi_url; - rate limiting:
capi_rate_limit(the trait) andcapi_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) andcapi_cookies(with itscapi_cookies_macro); - the two client wrappers,
capi_firewall(egress policy) andcapi_debug_dump(development-time exchange dumps); - the macro crates
capi_deriveandcapi_macro_support, and the dev-only test harnesscapi_test_core.
The other six workspaces each own one concern:
capi_wire— the wire model:capi_wire_model(the traits,Codec,CodecError, theBinding),capi_wire_derive,capi_wire_value(the runtime value tree),capi_wire_datetime,capi_wire_decimal.capi_auth— the network auth flowscapi_auth_jwtandcapi_auth_oauth2.capi_engine— the sans-io engines and the family’s own transport: theConversation/Enginecontract and the per-lane drivers incapi_engine(which re-exports the core’s carrier traits at the paths its engines use), the wire enginescapip_http1andcapip_http2, the VOIP stackcapip_sip,capi_sdp,capip_stun,capip_rtp, the two protocol cratescapip_redisandcapip_voip(each a marker beside the session its exchange yields), andcapic_native_client, the engine-backed client. Everything here stands oncapi_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 IRcapi_graphql_schema.capi_test_framework— replay testing, withcapi_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(theJsonmarker),capiw_ext_urlencoded(UrlForm, andurlform_extensionbehind its off-defaultmodelfeature),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 owntests/conformance.rs. - Transports (
capic_*):capic_reqwest,capic_ureq,capic_wasm_fetch(browser only),capic_reqwless(embeddedno_std). - Protocols and formats:
capi_websocket(RFC 6455 framing, theWebSocketcapability, correlated sessions),capi_grpc(unary, client-streaming, and server-streaming calls),capi_html_forms(the scrape-and-submit decoder),capi_http_batch(multipart/mixedbatch envelopes and OData changesets). - Auth add-ons: the signers
capi_auth_aws_sigv4,capi_auth_azure(Storage SharedKey),capi_auth_digest, andcapi_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 shipsStreamCodecimplementations forBytestream::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 thegeneric_llmworkspace. 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— thecapi_verifyplanner library and thecargo-capibinary, socargo capi verifyis the gate in any repo built on the framework;capi-extension-template, acargo generatetemplate for a new add-on; and the privatecapi_test_suite(the registry and the family sweep) andcapiw_conformance_tests(the multi-codec properties).
Adapters are bridges, and the family also ships its own. The
capic_*transports overreqwest,ureq, the browser’s Fetch API, andreqwless, and thecapiw_*codecs overserde_json,quick-xml, andprost, 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 optionalserdesupport. Beside the bridges sitscapic_native_client, the family’s own transport on thecapi_engineprotocol 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:
| Prefix | Role |
|---|---|
capi_rs | The public facade |
capi_core | The anchor: central traits + error taxonomy, re-exports sub-crates |
capi_protocol | The 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_rs | Client 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.
| Marker | Crate | Outbound → Inbound |
|---|---|---|
Http | capip_http | Request → Response |
Grpc | capip_http | Request → Response |
WebSocket | capi_websocket | Request → (Response, Option<UpgradedConnection>), and the browser’s message-stream twin |
Redis | capip_redis | RedisConnect → RedisSession |
Sip | capip_voip | SipConnect → SipEndpoint |
Media | capip_voip | MediaPlan → MediaSession |
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+allocso it also runs on bare metal. - Browser wasm —
wasm32-unknown-unknown, the JS-hosted lane. Its canonical cfg predicate isall(target_arch = "wasm32", target_os = "unknown"). No crate links the standard library here. The async surface and the relaxedSend/Syncbounds 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.
| Feature | Native and WASI | Browser 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 streams | Inert 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 |
async | Switches the trait shapes from blocking to impl Future; the same definitions compile either way | Always on — the surface is async regardless of the feature |
single-threaded | Drops the Send/Sync bounds graph-wide so !Send types flow through the framework | Implied 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:
| Token | Meaning |
|---|---|
"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 stdis gated off browser wasm. Browser wasm never links the standard library; there,stdis the platform-capability switch, not a library. Thecfgsays exactly that.- Reach for
alloc, neverstd. Spellalloc::string::String,alloc::vec::Vec,alloc::boxed::Box,alloc::formatin every module. The prelude (use capi_rs::prelude::*;) is the authoring surface for framework names; of thealloctypes it re-exports onlyCow,format, andToString, soStringandVecare imported where they are used. A straystd::path is a resolution error on the browser lane and in the featureless build. configandinterfaceare private modules, glob-re-exported. Users writeparcel_capi_rs::ParcelConfig, not::config::ParcelConfig, while the types stay exported.auth,decoders,endpoints,errors, andtypesarepubbecause 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 addingcapi_rsas 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:
| Trait | Crate | What the config supplies |
|---|---|---|
HasAuthStore | capi_core | the credential store the stored-credential schemes write |
HasCookieJar | capi_cookies | the jar a cookie-login flow reads the session from |
GrpcConfig | capi_grpc | protobuf_codec(), and optional max_message_size, compression, grpc_headers |
BatchConfig / ChangesetConfig | capi_http_batch | the 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 theLateseat. A required declaration resolves against it and the seal writes nothing, which lets an endpoint riding such a credential declare its tier honestly instead ofAuth::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;Skipplaces it as it stands). A failed renewal aborts the request even underoptional(): 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(..)andwith_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 anAuthSource, the pluggable backend consulted when the entry map misses: a vault, a keychain, a file. It answers afetch(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 a401round 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, andNONEguarantees 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 ownNONEendpoint — 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 shipsTokenandBasicfor 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’sauthenticate(...)becomesapi.authenticate(...)— and emits the delegating interface impls.flow::<S>()on the interface reaches every other scheme, andas_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’sauth-basicfeature).AuthorizationHeader::new(prefix, value)→ the generalAuthorization: <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 forlogin()vsset_token()vsexchange(); they reach forauthenticateand 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:
- 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. - 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’sauthenticate(...)isapi.authenticate(...), and the handle derefs toBaseInterfacein turn, soqueryandscopedare 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)]emitsDeref/DerefMutto the struct’s field and the delegatingApiInterface/ProvidesConfigimpls, 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 ownClientparameter rather than pinning one.FollowUpChannelcannot 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.rsdeclares the credential’s placement.auth.rsnames the default scheme’s owning handle and re-exports the markers users reach.interface.rshas your#[capi(interface)]wrapper whose one field is that handle (orBaseInterfacedirectly, for a signing/no-auth client).api.authenticate(…)andapi.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
| Strategy | How it wires | Proven by |
|---|---|---|
| None | the API type stores BaseInterface directly; endpoints Auth::NONE | a public endpoint |
| Bearer token | config AuthConfigExt::bearer(…); default scheme Token | grok |
| Bearer, named tier | the same scheme at a role: api.as_role("…").flow::<Token>() | grok (management) |
| Basic | config AuthConfigExt::new(…); default scheme Basic | icecast |
| API key in a custom header | config AuthConfigExt::new_with_auth_header(…, HeaderName); default scheme Token | google_places |
| API key in the query string | a scheme of your own writing AuthEntry::query_param — the worked example below | derive from the example |
| Custom scheme prefix | config AuthConfigExt::with_placement(…, AUTHORIZATION, "X "); default scheme Token | derive from the above |
| Fully custom value | write the store directly: config.auth_store().set_auth_entry(…), or a hand-written scheme | derive 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 renewal | paychex → this chapter’s flow note |
| Cookie login | config CookieConfigExt::new(…).session_auth("sessionid"); #[cookie_auth(endpoint = <Path>)] on the login carrier | → this chapter’s flow note |
| OAuth2 / OIDC / JWT, vendor-bridged | capi_auth_oauth2 / capi_auth_jwt schemes reached through flow::<S>(), plus a vendor crate’s config bridge | google_places → OAuth2, OIDC, and JWT |
| OAuth2 as the default scheme | the client implements ConfigOAuth2Grant itself and stores the grant’s owning handle (Oauth2Flow<BaseInterface<_, _>, OAuth2JwtBearer>) as its API field | ringcentral → OAuth2, 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 BaseInterface | aws_s3, azure_tables → Request 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 theTokenscheme storeAuthorization: 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
AuthEntryyourself and write it withconfig.auth_store().set_auth_entry(tier, entry).
Network and cookie flows (a pointer)
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’sauthenticate(...)takes the carrier’s body fields in declaration order, builds the carrier, converts it into the endpoint throughInto(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_tokenhands the decoded token back as well as storing it, andrefreshablemakes the credential one the store can date and renew. Underrefreshablethe derive also emits aRefresherthat re-runs the exchange, andauthenticatereturns anAuthenticated<_>whose.auto_refresh()seats it — so the call is spelledapi.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>plusExpiringToken(the credential’s own lifetime) for a refreshable one.paychexderives 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, fromcapi_cookiesrather than the derive — makes the carrier a login scheme:authenticate(...)runs a request whoseSet-Cookieresponse populates the cookie jar (the config must implementHasCookieJar), storing no header at all. It emits the same handle shape, so it is named inauth.rsand reached throughflowexactly 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 addscsrf = "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 keepAuth::DEFAULTand only the login — which cannot present the session it establishes — declaresAuth::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 machinery —
capi_auth_oauth2(grants, PKCE, discovery) andcapi_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 itsauthmodule re-exports the scheme markers users reach throughflow. Each marker is anAuthSchemewhose handle isOauth2Flow<I, Grant>orJwtBearerFlow<I>;Grantselects whichauthenticate…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 theOauth2Token.
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 CSRFstateon construction — and anoncewhen the provider defaults set a nonce length, and a PKCE challenge when they set a method or the client is public — and validates the returnedstatebefore 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 — callsauthenticate_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 CodeChallengeMethod — S256
(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’sOAuth2JwtBearergrant, 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’sJwtBearerscheme (api.flow::<JwtBearer>()), whoseJwtRefresherre-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.JwtClaimscarries 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-writtenWireEncodethat 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.JwtSigningKeyis 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, andkid()reports the key id the header carries.JwtAlgorithmisRS256(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 itsauth()declaration names — which is why a signed endpoint keepsAuth::DEFAULT(S3 usesAuth::DEFAULT.optional(), so an anonymous reader can still find a bucket’s region).Auth::NONEhands the signerNone, and a scheme that needs a key fails withSignError::missing_key. Two distinct key-material entries on one marker are refused asAmbiguousSigningKey.outputs— the headerssignwrites. The seal refuses a store placement into a declared output (PlacedOutputCollision): a placed credential the signature would overwrite is a misconfiguration worth naming. After eachsign, the names are stamped into the request’sInjectedCredentialsrecord, which is how fixture tooling classifies signer-written headers structurally — names travel, never values.needs_body— whether the carrier should buffer the body beforesignruns. A signer that reads the body itself reportsfalse, 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 everyOneShotSignergets 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_responseuntilDoneor 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)— mutatehead.headers/head.specs_mut(); take the material fromkey(SignError::missing_keywhen you need one and gotNone); read the body non-destructively withbody.inspect_data()if your signature covers it; draw any nonce fromctx.rng().outputs()— every headersignwrites.key()— leave itNoneunless the scheme signs with an entry other than the one the endpoint declares.needs_body()—trueonly if you want the carrier to buffer the body beforesignruns.handle_response(RequestSigneronly) — returnRetry(and setmax_retries()above 0) only if your scheme renegotiates; returnErrto reject an exchange you can’t verify; otherwiseDone.
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:
| Variant | When | Who fills it |
|---|---|---|
Api { error, annotations } | your API returned a parseable error body | you (E) |
Decoding { status, source } | a body failed to parse | framework |
UnexpectedStatus(StatusCodeError) | a 1xx/3xx, or an unmodellable status | framework |
Unrecognized(UnrecognizedError) | an error status whose body isn’t your error shape | framework |
Bytestream(BytestreamError) | the body stream itself failed | framework |
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 rawcode: 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
codeto an enum so callersmatchinstead of comparing strings. - Per-operation enums — when different operations return genuinely different
error shapes, give each its own
E.aws_s3goes all the way here: seventeen per-operation error enums plus a sharedS3Errorfor every operation the S3 model does not enumerate, all built from a singleS3ErrorEnvelope(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 downcasting —
err.kind()returns anErrorKind(Authentication,InvalidRequest,Transport,RetriesExhausted,StatusClient,StatusServer,Api,Decoding,Io,Cancelled, …), and theis_*()family reads ergonomically:err.is_rate_limited(),err.is_retryable(),err.is_transport().Cancelledis 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, sois_retryable()is false and the retry runner stops on the first attempt. The knobs that fire without ending the call for good keepTransportand read back througherr.is_timed_out()/err.is_stalled(); a knob the client could not enforce at all isInvalidRequest, witherr.refusal()naming it. - Read metadata generically —
err.metadata()surfaces the code/message yourProvideErrorMetadataexposed, no downcast needed; it is populated when the decoder captured it through the envelope ladder, andNonefor non-Apierrors.err.status()answers for anUnexpectedStatus, anUnrecognized, or aDecodingbuilt with a status — for a modeledApierror the status lives in your envelope (envelope.http_status), sostatus()isNonethere 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 —Throttlingfor a 429,Transientfor 408, 500, 502, 503, and 504, or whatever the envelope’s ownretry_kind()says — so anApierror can be retryable too;is_rate_limited()is that classification or a 429 status.- Recover the typed error —
err.api_error::<GrokError>()downcasts and returnsOption<&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 honestResponseError::Decoding(theCodecErrorbridges via.map_err(Into::into)) — never a fabricatedApi. 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 yourApi(E)from the envelope; failing that, from the status alone throughFromErrorEnvelope::from_status(the rung a bodylessHEADneeds); failing that, anUnrecognizedthat preserves the raw status and bytes. - 1xx/3xx →
UnexpectedStatus. A status the decoder can’t model — including a redirect, surfaced asStatusCodeError::Redirection { status_code, location }.ResponseError::unexpected_status_with_headers(status, &headers)builds it with theLocationlifted 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:
| Trait | Use when |
|---|---|
DecodeBody | you want to write the decode inline on the endpoint |
DecodeBodyFn | you share a crate-wide decoder function (the common case) |
DecodeWrappedBodyFn | the wire body is a wrapper and Output is its inner field — decode the wrapper, return the inner |
TryDecodeWrappedBodyFn | same, 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_errorhook to run the samebuild_api_error. grok’s streaming and download decoders do exactly that — reuse the sharedbuild_api_errorrather than re-deriving it. The chunked decoder has no such hook at all. (See Streaming.) - A
Decodingbuilt from?carries no status.From<CodecError>maps toResponseError::decoding(source), whosestatusisNone, 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 withdecoding_with_status(status, source)so a caller’serr.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:
| Convenience | The problem it answers |
|---|---|
| Tri-state fields | PATCH bodies must distinguish unchanged, set, and explicitly null |
| Open string enums | a documented value set that the service will grow later |
| Number laning | APIs that quote numbers as strings, and money that must never be f64 |
| Datetime contracts | epoch vs RFC 3339 vs a bespoke format — where a wrong unit is the costliest bug |
| Sentinel mapping | services that encode absence as "", -1, or 9999-12-31 |
| Value classes and redaction | marking a field secret or non-deterministic, and a typed transform so a request is born without its secrets |
| Constant entries | a "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 —
Codec<F>, the markers, and the object-safe machine underneath. Read this one. - Describing a Type — the three derives, the
Binding, naming, and presence. - Shaping Structures — wrapping, flattening, proxies, and the tri-state PATCH model.
- Scalars, Numbers, and Money — number lanes, byte encodings, decimals, and value classes.
- Dates, Times, and Durations — the largest attribute family, and the one most worth getting right.
- Enums on the Wire — the four taggings, open string enums, and catch-alls.
- Format Extensions — advanced: how a format contributes its own vocabulary, and how to write one.
- Hand-Written Impls and the Value Tree — when the attributes run out.
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 onF. A config stores a codec by value — noArc, no lifetime, no reference counting — and staysCopyitself. ThePhantomData<fn() -> F>is what keepsCopy/Send/Syncavailable for everyF; a naivePhantomData<F>would dragF’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 aconst fn, so a codec can be astatic, and copying it is trivial. Fis 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_vectakes an erased value (&dyn DynEncode); a concrete value coerces at the call site —codec.encode_to_vec(&user). It returns a freshVec<u8>;encode_intoappends to a buffer the caller owns instead.decode_from_slice::<T>is the inverse: bytes in, a typedTout. The one mandatory allocation is a boxed decoder; monomorphization is confined to this call.erased_decoderhands 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’sContent-Typeheader; an endpoint owns that (vendored types likeapplication/vnd.foo+jsonare 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.
Formatis 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 speaksG, and prefererase()at the boundary over laundering handles throughassume.
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.
| Format | Contract crate (the marker) | Concrete codec crate (the engine) | Default content type |
|---|---|---|---|
| JSON | capiw_ext_json (Json) | capiw_serde_json | application/json |
| URL form | capiw_ext_urlencoded (UrlForm) | capiw_urlencoded | application/x-www-form-urlencoded |
| XML | capiw_ext_xml (Xml) | capiw_quick_xml | application/xml |
| Protobuf | capiw_ext_protobuf (Protobuf) | capiw_prost | application/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:
WireModelemits aconst BINDING: &'static Binding— a static description of the type’s wire shape, the single source of truth a codec reads; tooling can readT::BINDINGto learn a type’s shape without instantiating it. It also emits two impls the description implies:WireShape, the recursiveShapeevery field binding stores for its type, andRedact, the typed redaction transform that replaces the model’ssensitivefields with placeholders. TheRedactemission has a compile-time consequence: every non-skipfield’s type must implementRedact— 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).WireEncodeimplementsfn encode<E: Encoder>(&self, encoder: E)— the value pushes itself into whatever encoder a codec provides.WireDecode<'de>is the inverse, building aTfrom 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,
}
renamesets 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_allrecases field names that lack an explicit rename. Acceptslowercase,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,
}
defaultmakes a field optional on the wire and fills an absent one fromDefault::default()or a named function. On anOptionfield,default = "path"fills the absent case from the path rather than collapsing toNone.skipremoves 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_noneis accepted and changes nothing:Nonealready omits. A field-level predicate takes precedence over it.skip_encoding_ifnames afn(&FieldType) -> bool. Decode is unaffected, so a non-Optionfield omitted this way needs#[wire(default)]to decode back. Struct fields only; not combinable withskiporflatten.
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>,
}
| State | On the wire | Meaning |
|---|---|---|
Keep (the Default) | omitted entirely | leave the server’s value alone |
Set(v) | the value | set it |
Clear | explicit null | clear 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):
| Kind | Types |
|---|---|
| booleans and numbers | bool; i8–i64, i128, u8–u64, u128; f32, f64 — no usize/isize, whose width is the platform’s |
| text and bytes | str, 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) |
| presence | Option<T>, Tristate<T> |
| containers | Vec<T>; BTreeMap<String, V> — the only map, so encoded objects are canonical |
| carriers | the 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:
| Knob | Effect |
|---|---|
as = "string" / as = "number" | a quoted string on the wire, or the native number lane (the default) |
scale = N | a 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 newtype —
FieldMask<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:
| Type | Shape |
|---|---|
Decimal | signed 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 is | Field type | Bare default |
|---|---|---|
| an instant on the timeline | Timestamp | none — the attribute is required |
| a civil date + clock + fixed offset | DateTime | RFC 3339 |
| a calendar date, zone-free | Date | ISO 8601 (YYYY-MM-DD) |
| a clock time, zone-free | Time | ISO 8601 (HH:MM:SS) |
| a span | core::time::Duration | none — 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 requireZ). ForTimestampstring formats the default is UTC; forDateTimethe 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)emits1355517523.000005as 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,subsectruncation). Defaultfloor— time truncates by convention."unnecessary"errors instead of silently losing precision.accept = "..."(repeatable) — alternate formats honored on decode only. Encode always usesformat; decode triesformatfirst, then eachacceptin 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_decode | Reading 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:
| Name | Matches | Origin |
|---|---|---|
@zero-date | 0000-00-00 (and the datetime form) | MySQL zero date |
@epoch | the exact instant 1970-01-01T00:00:00Z | epoch-zero defaulting |
@end-of-time | date 9999-12-31, any clock | SQL “no end date” |
@smalldatetime-max | date 2079-06-06, any clock | SQL Server smalldatetime ceiling |
@sqlserver-min | date 1753-01-01, any clock | SQL Server datetime floor |
@ole-zero | date 1899-12-30, any clock | OLE 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 arei128Unix nanoseconds; leap seconds do not exist (a wire23:59:60clamps to59on parse and is never emitted). - Offsets are fixed — a
TimeZoneis a whole-second offset east of UTC (TimeZone::UTC,from_fixed_offset_secs), read back throughDateTime::time_zoneand re-attached withwith_time_zone; no tzdb, no DST. Zone-aware logic belongs in the application, converted at the boundary via the interop features:jiff,chrono, andtimeare symmetric optional features that addFrom/TryFromconversions 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) backsTimestamp::nowandDateTime::now_utcwith the system clock, and on browser wasm itsjscapability reads the browser’s clock and normalizes JS-flavoured date values. DateTimecompares by instant; it also offersparse_http_date/format_http_dateand the flexibleparse_iso8601(calendar, week, and ordinal forms).Date,DateTime, andTimehaveparse_strftime/format_strftime;Timestampis an instant with no calendar form of its own, so it does not.- The
durationmodule parses and formats ISO 8601, Go, and protobuf duration text; thecalendarmodule 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/aliasand the containerrename_allare rejected (a struct variant’s own field renames stay in effect), and untagged unit variants aren’t supported — model the absent case withOption.
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
}
| Class | Meaning | Recorded | Compared |
|---|---|---|---|
Plain (default) | an ordinary value | in full | in full |
volatile | non-deterministic — timestamps, nonces, request ids | verbatim | by presence and lane only |
sensitive | secret | masked | by 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.
| Plane | Runs when | Reaches | Placeholder keys |
|---|---|---|---|
Typed — the Redact transform | a model exists before there are bytes: requests | whatever the model declares | redacted / redacted_fn |
| Byte — the recorder’s masking | only bytes exist: responses, streams, undeclared secrets, non-model bodies | whatever the decoded tree holds | mask (= 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:
- A marker. One zero-sized type —
Xml,Protobuf,UrlForm— so a codec speaking the contract returnsCodec<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. - A decoration macro, behind a
modelfeature.#[xml_extension],#[proto_extension],#[urlform_extension]— each accepts format-shaped attributes and lowers them into the coreextbag. 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. - Typed readers. Functions like
place(),root_name(),xmlns(),field_number(),list_convention()that read the bag back and return a real type instead of anOption<&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:
- 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. - Decide your keys, namespaced (
"myfmt.thing"), and write typed readers over them. - If your format needs author-facing annotations, add a decoration macro behind a
modelfeature 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 ByteDecode — WireFormat 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 decode —
WireEncodeandWireDecode<'de>, or the one-traitWireScalarform for a leaf; - a shape —
WireShape, the recursiveShapethe field binding stores for the type, which the model-driven walker and fixture tooling read; - a placeholder —
Redact, which theWireModelderive requires of every non-skipfield 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
binding — WireModel 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
codec — to_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 (method → url → headers → auth → accept →
query_string → content_type → body → rate_limit → signer), 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:
| Read | For |
|---|---|
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(), notquery().query()is the call funnel on the interface; the endpoint method that builds the query string isquery_string. body(self, …)consumesself, 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 byquery_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 anOption(defaults toNone), abool(false), or aVec(empty). The constructor allowsclippy::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 isimpl Into<T>; aVec<T>takesimpl IntoIterator<Item = T>for a primitiveTandimpl IntoVec<T>(any iterator of itemsInto<T>) otherwise. The primitive match is syntactic, so an alias such astype Grams = u32;reads as non-primitive — mark the field#[capi(no_into)]. - A getter per private field, named after the field.
- A rustdoc
# Parameterssection 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:
location | Goes to | Consumed by |
|---|---|---|
"path" | the URL path (via path_template placeholders) | url() via self.path_string() |
"query" | the query string | query_string() via self.query_fields() |
"body" | the request body | body() via self.body_fields() |
"header" (or location(header)) | a request header | headers() 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>.rs — endpoints/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 Endpointwith the five associated types andmethod+url, overriding only what varies; - one line of decoder wiring (
impl DecodeBodyFnnaming 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_bodyvsencode_to_vec. Different methods, different jobs.encode_body(&value)is the request-body encoder: it returns anEncodedBodyready to be a body, spec included.encode_to_vec(&value)returns a bareVec<u8>and is for the streaming and WebSocket paths that hand raw bytes elsewhere. In an endpointbody(), you wantencode_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
FileBlobfield with nolocationattribute, returned straight frombody():#[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
FileBlobas alocation = "body"scalar field encodes as a base64 / data-URL string (it’s aWireScalar), 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 defaultserializer = "form"renders the value throughDisplay;serializer = "json"and friends encode throughconfig.{json}_lib().encode_to_vec(..), as a typed part whencontent_type = "…"is given and as a bare stream otherwise. -
A stream part —
kind = "stream"— flows throughTryInto<Bytestream>intoPart::stream, so a large upload never buffers in full.file_name = "…"andmime_type = "…"attach literal metadata. -
A metadata-only field —
file_name_for = <field>and/ormime_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 field —
kind = "one_of"— is a field whose type implementsMultipartPartsand 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>— aNonefield is omitted on encode and an absent field decodes asNone; that is the wire model’s own rule, with no attribute needed. #[wire(tristate)]overTristate<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 content —
Output = (), decoded by a helper that succeeds on 2xx and ladders errors otherwise (grok’s sharedempty; S3’sdecode_headers_only_with_headersfor a headers-only reply). Nobody()override needed. - A confirmation body — some DELETEs return
{ "deleted": true }. ModelOutput = booland unwrap it withDecodeWrappedBodyFn(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.
headtakes&Endptand requiresEndpt: Clone; the clone rides inHeadOf, so the original stays intact for the follow-upquery. A non-Cloneendpoint cannot be probed. - It runs through the chain’s runner.
headaccepts anything that opens a scoped request, so per-request overrides ride along andapi.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:
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.prepare_request(endpoint, config, context, &mut head)runs during assembly, after the required capability’s ownprepare_requestand 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 theEndpointmethods 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.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 returnsEndpt::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:
| Lane | Marker | Companion trait | Chapter |
|---|---|---|---|
| Buffered body | BodyDecoder<S> | DecodeBody, DecodeBodyFn, DecodeWrappedBodyFn, TryDecodeWrappedBodyFn | the decoder contract |
| Raw bytes | BytestreamDecoder | DecodeBytestream | streaming |
| Resumable download | ResumeDownloadDecoder | ResumableDownload | streaming |
| SSE, chunked, documents | SseDecoder, ChunkedDecoder, DocumentStreamDecoder<Framing, S> | DecodeSse, DecodeChunked, DecodeDocuments | streaming |
| Pages | PagedDecoder | DecodePaged, DecodePagedSimple, PaginatedResponse | pagination |
| Switching | SwitchDecoder | SwitchDecode | switching |
| Resumable and flat-map streams | StreamFnDecoder, FlatMapDecoder | DecodeResumable + Resumable, FlatMapDecode | composing streams |
| Batch envelopes | the capi_http_batch envelope decoders | — | batching |
| WebSocket | WebSocketDecoder | DecodeWebSocket | WebSocket |
| gRPC | GrpcDecoder, GrpcStreamDecoder | — | gRPC |
| HTML forms | FormFlowDecoder | FormFlow | HTML 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:
| Framing | Output | Decoder | Companion trait |
|---|---|---|---|
| Server-Sent Events | StreamIterator<T> | SseDecoder | DecodeSse |
| Whole documents — a root JSON array, whitespace-separated JSON, lines/NDJSON, RFC 7464 | StreamIterator<T> | DocumentStreamDecoder<Framing> | DecodeDocuments, or a strategy |
| HTTP chunked | StreamIterator<T> | ChunkedDecoder | DecodeChunked |
| Raw bytes | BinaryStream | BytestreamDecoder | DecodeBytestream |
| A resumable download | FileStream<Headers> | ResumeDownloadDecoder | ResumableDownload |
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_pagethat never returnsNone— a server cursor that doesn’t advance — produces an infinite stream. Make sure your stop condition actually triggers.
Cursor, offset, and Link-header schemes
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_tokeninto the next request. grok’scustom_voicesand S3’sListObjectsV2both 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 theLinkheader and follow the absolute URL (via an absolute-URL endpoint).capi_base_decoders::LinkHeaderis the parser:LinkHeader::of(&headers).next(&base)(or.rel("next", &base)) resolves the relation against the request’s URL and hands back aUrlBuilder. - A continuation in the response headers — Azure Tables carries its cursor in
x-ms-continuation-NextPartitionKey;next_pagesees only the decoded body, so the body decoder lifts the header values onto the page type first, andnext_pagereads 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 head — decode_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:
| Axis | Who decides | Mechanism |
|---|---|---|
| 1. You, before sending | your code picks a variant | OverrideEndpoint (.stream(), .deferred()) |
| 2. The server, in its response | the response’s shape decides | SwitchDecoder / SwitchDecode |
| 3. Fetch strategy | one 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.
DecodeResumableis the connection decode: aParser(aStreamParserfor the framing), anItem,parser(headers, config), anddecode_item. It is implemented on the initial endpoint and on its resume endpoint, returning the samedecode_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.Resumableadds the reconnect policy: theCursorandResumeEndpointtypes, 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, aRangeacknowledgement.validate_continuation(..)(onDecodeResumable) checks that a resumed response answers this request — a206 Partial Contentcontinuing at the right offset — and routes a rejection throughdecode_errorlike an admission failure.max_reconnects()is the framework’s only built-in ceiling, on successful reconnects; it defaults toNone, so a policy that always returnsReconnectis an infinite loop by the implementor’s own choice, andctx.attemptis 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 ownon_breakreconnects to the candidate it was on, with the policy’s backoff — when the endpoint is used on its own. Under the flatten,innersetsReconnectPolicy::never(), so a candidate is one bounded pass. - Layer 2 — candidate failover: the same
on_break, out of same-mount retries, bumps theFailovercursor to the next mount in the playlist and reconnects there at once. - Layer 3 — playlist re-resolve:
on_end’sRefetchPolicy, 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 download — Range 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:
| Trait | Verb it enables | Output |
|---|---|---|
BatchConfig — batch_url(), batch_profile() | api.batch(members, &overrides, &rng) | a flat envelope; parts fail independently, so Vec<Result<E::Output, _>> in request order |
ChangesetConfig — changeset_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
- 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 onemultipart/mixedbody ofapplication/httpparts, with boundaries drawn from the RNG handed in. - 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. - Decode. The response envelope is split positionally —
Content-IDechoes 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()returnsNonewhen the connection is cleanly closed — but drain on the protocol’s terminal message, not onNone. On the rawWsConnectiona clean close arrives asSome(Ok(WsMessage::Close(..)))before the finalNone, and an abrupt transport EOF surfaces first asSome(Err(..)). On the typed connection a peer’sCloseis fed through yourIndecoder first, so it reaches you as whatever that decoder makes of aWsMessage::Close— an error, in the usual impl — and thenNone. Either way, loop until your protocol’s own end-of-session signal, treatingNoneas “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-Acceptagainst 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_reqwestupgrades on its async lane only: a blocking build compiles, and the send fails withWebSocketRequiresAsync, because reqwest’s blocking client has no upgrade API.capic_urequpgrades over tungstenite and hands back a message-level connection;capic_native_clientperforms the genuine wire upgrade on both lanes. - The browser —
capic_wasm_fetchderivesws://orwss://from the endpoint’shttp(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-Lengthand 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.Buffereddrains 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 acontent-length. The fixture records that it was buffered, so it says plainly that the recorded run was not the streaming run.Spooledlets 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
variablesobject, and the struct converts intoGraphqlCallthroughIntoEndpoint, soquery()takes it directly. Nullable variables and input-object fields areTristate<T>, keeping GraphQL’s absent/null/value distinction; a schema’s enums and input objects reach a document throughArgumentLiteral. - Decode types are named for the schema. They sit in a module named after
the operation, where
Rootis the decodeddataobject 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 aGraphqlErrorResponsethat preserves any partial data alongside the spec’s error objects (message, locations, path,extensions). That envelope is every operation’sEndpoint::Error—GraphqlErrorResponse<Op::Data>, so a library writes no domain error enum — and a caller recovers it typed witherr.api_error::<GraphqlErrorResponse<Option<get_user::User>>>(), then reads.errors()and.data(). A non-success status without an envelope isUnexpectedStatusand a 2xx that isn’t one isDecoding: 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’sgraphql!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. Underschema_module = declaredthe macro emits no vocabulary of its own and resolves against the library’s hand-writtenschemamodule.- 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 — andcheck_operationsverifies the committed SDL still satisfies them, reporting in the schema’s own language. The checker is theconformancefeature, a host-only test surface (the schema IR lives incapi_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!, andoperation_documentproves the two send byte-identical documents — so a selection, alias, or argument that drifts from what was meant fails a test. - Queries can ride GET —
api.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. TheQueryOperationmarker is what keeps mutations off the GET transport at compile time. - Persisted queries (
apqfeature) —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 (
uploadsfeature) —Upload-typed variables travel as multipart file parts per the GraphQL multipart request spec, streamed on native transports; the request JSON rides beside them withnullat each file position, andapi.query(op)works unchanged. - Subscriptions —
subscriptionoperations run over GraphQL-over-SSE in distinct connections mode:api.query(op)returns a stream of the operation’s data type, eachnextevent decoding through the same envelope semantics as every other response, until the server completes. One root field per subscription. For graphql-transport-ws servers thewsfeature adds a WebSocket session, shaped by lane: on the async laneGraphqlWsSession::handshakethensplit()yields a caller-spawnedGraphqlWsDriverand aGraphqlWsHandlewhosesubscribereturns per-subscription typed streams demultiplexed by id over one connection; on the blocking laneGraphqlWsSubscription::openruns one subscription per connection with a blocking pump. The config’sGraphqlWsConfigsuppliesconnection_params(theconnection_initpayload, where a token rides) and agraphql_ws_urlthat defaults tographql_url. - Fragments and spreads compose selections, in the library’s source and
across
graphql!invocations alike; theScopeassociation 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. @batchrepeats one root field with per-entry inlined arguments underb0,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:
| Order | Step | Who runs here | Kind |
|---|---|---|---|
| 1 | Phase::Early | members that must see the request first (request identifiers, trace stamps) | member |
| 2 | Phase::User | the config’s caller-supplied members, and where a per-request delta merges | member |
| 3 | Phase::Mid | members after the caller’s, before the near-wire ones | member |
| 4 | Phase::Late | near-wire mutation: a cookie jar reads here | member |
| 5 | Gate | the rate-limit wait; the lane sleeps | plane |
| 6 | Seal | credential resolution — renewing a seated credential — placement, then the resolved signer | plane |
| 7 | Transmit | hop-by-hop validation, the Host strip, the capability send, transfer stages | traversal |
| 8 | Unwind | response halves, in reverse order | member |
| — | (decode) | below the pipeline | decoder 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
Hoststrip 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 RateLimit — None, 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 OKthat says{ "retryable": true }; a member can’t see that.WaitUntilRunner’s predicate reads the decoded output;RetryRunner’s ranges over theQueryErroran attempt produced. - Redirect following,
Retry-Aftercompliance, and reactive credential renewal on a401— 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 addsEndpt: Clone, because re-dispatch needs a fresh endpoint — while the trait stays unconstrained. Keep constraints on the impl, not the trait. Outputreshapes freely. Transparent runners settype Output = Endpt::Output; a reshaping chain returns something else (e.g.(ResponseHead, Endpt::Output)from a head capture).RunnerAccessis the handle you dispatch through, and it has exactly two ways to reach the network:exchange(endpoint)performs one round trip and yields the undecodedApiResponse, andexecute(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 insideRunner::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):
| Runner | Sugar | Kind | What it does |
|---|---|---|---|
RetryRunner / RetrySpec | api.retry(spec) | wrapper | re-dispatches on a retryable QueryError, with backoff; with_predicate(spec, decide) takes a Fn(&QueryError) -> bool |
WaitUntilRunner / WaitSpec | api.wait_until(spec, pred) | wrapper | polls until the predicate over the decoded output holds (below) |
RetryAfterRunner / RetryAfterSpec | api.retry_after(spec) | dispatcher | complies with a server-directed Retry-After |
RedirectRunner | api.follow_redirects(n) | dispatcher | follows a Location by re-describing the endpoint as a RedirectHop<Endpt>, decoded through HopDecoder |
RefreshRunner | api.refresh_on_unauthorized() | dispatcher | renews 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 member | Runner | |
|---|---|---|
| Layer | one request, one response | decoded output |
| Sees | headers, status, the plan-time body view | typed 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 |
| Composition | phase, then insertion order | later 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
- 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 intounexpected_status_with_headers,Locationincluded. (A page that redirects isapi.follow_redirects(n).query(flow)’s job; see below.) - Extract the form by the endpoint’s
form_selector()—FormSelector::id("edit-profile"),::name("login"), or::css("form.login"). - Apply the updates the endpoint’s
form_update()returns to the form’s fields; a field the form does not have is aFormError. - Resolve the action the way a browser would: a relative
actionagainstpage_url(), an empty or missing one to the page itself, an absolute one as-is. - Submit — a follow-up exchange through the door, built as a
SubmissionEndpointover the flow endpoint. - 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
HasUrlEncodedLib — url_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
| Adapter | Client | Camp and lane | Construction |
|---|---|---|---|
capic_reqwest | ReqwestClient | native; blocking, or async with its async feature | ReqwestClient::default(), or new(reqwest::Client) to bring a configured client |
capic_ureq | UreqClient | native; blocking only — it forbids the async axis | UreqClient::default(), or new(ureq::Agent) |
capic_native_client | NativeClient | native; both lanes. The family’s own sans-io engines over OS sockets — no third-party HTTP library in the exchange | NativeClient::new() / default() |
capic_wasm_fetch | WasmClient | browser wasm only; async by nature of the platform | WasmClient::new() / default() — a unit struct over the Fetch API |
capic_reqwless | ReqwlessClient<T, D> | embedded no_std; async, and single-threaded is required. MSRV 1.91 for its embassy-net/smoltcp graph | ReqwlessClient::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.
| Adapter | Http | WebSocket | Grpc |
|---|---|---|---|
capic_reqwest | always | websocket 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 API | grpc feature, which implies trailers and therefore async. A dedicated HTTP/2 prior-knowledge lane, with grpc-status surfaced through the trailer cell |
capic_ureq | always | websocket feature. The handshake runs over tungstenite on ureq’s TLS stack and yields a message-level connection | — |
capic_native_client | always, both lanes | websocket feature, both lanes — the genuine wire 101 handed to capi_websocket’s framing | grpc feature, both lanes — h2c by prior knowledge on http, ALPN h2 on https, trailers included |
capic_wasm_fetch | always | websocket 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_reqwless | always | — | — |
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.
| Adapter | TimeoutCapable | StallCapable | CancelCapable | Mechanism |
|---|---|---|---|---|
capic_native_client | both lanes | both lanes | both lanes | governed 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_reqwest | both lanes | async only | async only | whole 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_ureq | yes | no | no | ureq’s per-call timeout, and socket deadlines on the WebSocket handshake (cleared at the session handover). Neither other knob compiles against it |
capic_wasm_fetch | yes | yes | yes | one AbortController covers all three; the governor’s per-wait sleep is min(stall, remaining) for the fetch and every chunk read |
capic_reqwless | not claimed | not claimed | not claimed | embedded 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_rsboots four binaries on an emulated Cortex-M4 (QEMUmps2-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 overcapic_reqwlesson an in-binary smoltcp loopback. Exit code 0 undercargo runis the verdict. Its.cargo/config.tomlcarries the--cfg getrandom_backend="custom"that bare metal needs, and the crate pins Rust 1.91 for thenetlane’s embassy-net/smoltcp/reqwless graph.runtime_canary_capi_rsdrives the pipeline end to end under two executors the family links nowhere else —futures::executor::block_on, the bare poll loop with no reactor, andsmol— 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
decode — ExtensionOf<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:
| Target | Response envelope | prepare_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:
| Question | Accessor |
|---|---|
| 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], withasyncand every.awaitstripped; - 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:
| Lane | Bundled live client |
|---|---|
native, reqwest feature | capic_reqwest |
native, native-client feature (without reqwest) | capic_native_client |
| browser wasm | the Web Fetch client, no feature needed |
everything else — plain std, single-threaded, bare metal | none: 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:
| Class | Recorded as | Replay compares |
|---|---|---|
Plain | verbatim | exact |
Volatile | verbatim | presence and kind |
Sensitive | masked placeholder | presence and kind |
Omitted | not recorded | not compared |
Required | verbatim | presence, 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:
| Lane | In | What it runs |
|---|---|---|
manifest-lint | gate | the static rules below, over cargo metadata and the toolchain pin |
gate | gate | per crate: host clippy in the fixed lanes, the browser-wasm cargo check, and the lane-matched test runs |
doctests | gate | cargo test --doc in the documented lane — defaults plus async and the matching harness lane |
fmt | --full | cargo fmt --all --check |
fixtures | --full | the fixture attestation and format gate |
clippy | --full | per-crate cargo hack clippy --feature-powerset, host and browser wasm, plus the --all-targets passes for the default and async harness lanes |
tests | --full | per-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 | --full | cargo test --target wasm32-unknown-unknown --no-run — a compile check of the wasm test binaries |
bare | --full | clippy powersets on thumbv7em-none-eabihf and riscv32imac-unknown-none-elf with --cfg getrandom_backend="custom"; std/js and platform-bound features excluded |
loom | --full | model checks under --cfg capi_loom, in an isolated target/loom |
docsrs | --full | cargo 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 | --full | the 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 | --full | cargo 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, orsingle-threadedmust 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 localjsfeature included in itsstd. - E — no retired
wasm-jsfeature may exist. Not allowlistable. - F — the
asyncandsingle-threadedaxes 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.tomlpins exactly the highestrust-versionits 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 runner — DirectRunner 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 intoAuthSettings(one requirement per credential: the key to look up, whether a miss is tolerated, where it rides);Auth::NONEattaches 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()andsigner(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
RequestPlan — RequestPlan::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.
| Property | prepare() (consumes) | prepare_follow_up() (borrows) |
|---|---|---|
| Clock | inherited | inherited |
| RNG | forked | forked |
ContextId | fresh | fresh |
| Extensions | fresh, empty | shared with the parent |
| Notifications | child sender, bubbling to the base | inherited lineage |
| Budget | carried across | carried 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_time—Instantfor elapsed time and deadlines,SystemTimefor epoch timestamps, andApiClock, a clonable facade over aClockProviderwithnowandsleep. The backend is compile-time: the standard library withfutures-timerunderstdon native and WASI; the JS host clocks and gloo timers underjson browser wasm (included bystd, the platform switch there);embassy-timein embedded builds; and a stub — monotonic time fixed at zero, no wall clock, no meaningful sleep — when no backend is selected.capi_lock—SyncMutex,SyncRwLock,SharedCell,SyncCondvar(understd), andAsyncMutex/AsyncRwLock(underasync):parking_loton nativestd,spineverywhere else including browser wasm,async-lockfor 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 intoApiContext. ANotificationSender(attached withwith_notifications) fans structured events — aNotificationwrapping aNotificationKind, the built-inLogNotification, the transport’sTraversalEvent, a resumable stream’sReconnectNotification, a transfer stage’sProgressNotification— to everyNotificationReceiver. Delivery is non-blocking, disconnected receivers are pruned, andctx.notify(..)with no sender attached costs nothing. Senders propagate throughprepare/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 anEgressPolicyagainst 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:DebugClientwrites 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:
| Variant | From |
|---|---|
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 |
Backoff | the 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 |
Rng | the 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:
| Variant | Meaning | kind() |
|---|---|---|
TimedOut {} | the whole-call deadline passed before the response head arrived | Transport |
Stalled {} | one wait on the peer outlived the per-wait stall bound | Transport |
Cancelled {} | the request’s cancel token fired | Cancelled |
Refused { refusal } | the client cannot enforce a knob the request carries; nothing was sent | InvalidRequest |
Client(ClientErr) | the client failed on its own terms — DNS, TLS, a protocol violation | Transport |
Middleware(anyhow::Error) | a pipeline member, the seal, or a transmit invariant refused | Transport |
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:
| Variant | Shape | Meaning |
|---|---|---|
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:
| Kind | Arms that land here |
|---|---|
Authentication | Authorization(_) |
InvalidRequest | every Request(_); Transport(Refused) |
Transport | Transport(TimedOut | Stalled | Client | Middleware); Response(Bytestream(TimedOut | Stalled)); an incomplete Bytestream |
Cancelled | Transport(Cancelled); Response(Bytestream(Cancelled)) |
StatusClient | UnexpectedStatus(Client); Unrecognized with a 4xx |
StatusServer | UnexpectedStatus(Server); Unrecognized with a 5xx; Decoding whose recorded status is a 5xx |
StatusRedirect | UnexpectedStatus(Redirection) |
Api | Response(Api) |
Decoding | Decoding with no status, or a non-5xx one |
Io | any other Bytestream failure |
Other | UnexpectedStatus(Other); Unrecognized with a 1xx/2xx/3xx |
RetriesExhausted | reserved 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) -> Selfbuilds yourEfrom a parsed envelope;from_status(u16) -> Option<Self>maps a bare status, defaulting toNone. The ladder’s two rungs.ProvideErrorMetadata—code(),message(),request_id(), andretry_kind(), each defaulting toNone; implement the ones your API provides, and the capture site derives a status-basedRetryKindwhen the body offers none.ErrorMetadata—{ code, message, request_id: Option<String> }, surfaced byQueryError::metadata().ErrorAnnotations—{ metadata: Option<ErrorMetadata>, retry: Option<RetryKind> }, the box theApivariant carries when the decoder opted in throughfrom_envelope_or_unrecognizedorapi_annotated; empty otherwise, so the un-annotated path stays allocation-free.UnrecognizedError—{ status, body: Vec<u8> }withtext(), preserving the raw error body when it is not your shape.RetryKind—Transient(5xx, request timeout: back off),Throttling(429: wait the delay),Terminal;RetryKind::from_statusderives 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
ApiConfigand 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 contract — capiw_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 carryunsafe, and an optional feature that swaps in a faster backend withunsafeinside 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 optionalserdesupport; 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, andcapi_http_typesexist 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 —EntryRefresherandAuthSource, whoseRefreshFutureandAuthSourceFuturealiases spell it — returnPin<Box<dyn Future + Send>>rather thanasync fn. Where a trait must be object-safe, the compiler cannot proveSendthrough a GAT projection in an async context, so a Tokio-movable future is hand-constructed and explicitlySend(rust-lang/rust#100013). Callers still.await; theSendbound drops away on thesingle-threadedlane and on browser wasm. - Additive features, target-selected platform.
stdswitches surfaces on and never selects the platform;asyncflips trait shapes;single-threadedrelaxes the bounds graph-wide. The compilation target picks the platform half, and theassert_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, orpanic!in library code. Where a step seems to require one, the API is redesigned instead; where the stake is highest, a crate-rootdeny(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 redactingDebugand#[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.
| Crate | Job / key exports |
|---|---|
capi_rs | The facade — re-exports the members under short module aliases, plus prelude and reexport |
capi_core | Endpoint, OverrideEndpoint, IntoEndpoint, ApiConfig, ApiInterface, ResponseDecoder and the body-decode traits, the error taxonomy, RequestSpecs, ApiRedact, presign |
capi_protocol | The 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_http | The HTTP protocol: Http / Grpc, Capability, Supports<Cap>, ResponseEnvelope / ExtensionOf, FollowUpChannel, ClientFuture |
capi_transport | Traversal and the seal; ApiRequest / ApiResponse / ScopedRequest; Runner / RunnerAccess / DirectRunner; FollowUp, Finalize, PackagedRequest, PackagedResponse |
capi_middleware | Pipeline, Phase, the three member traits, RequestFlow / ResponseFlow, Body, TransportError |
capi_authentication | AuthStore / AuthEntry / AuthKey / AuthSettings, RequestSigner / OneShotSigner / Signing / NO_SIGNING / ToSigner, Cosigning, KeyMaterial custody, EntryRefresher / Freshness, presign_url / begin_policy / finish_policy |
capi_rate_limit | RateLimiter, WaitableRateLimiter, RateLimit, ToRateLimiter, LimiterUnit, NO_RATE_LIMIT |
capi_limiters | GcraLimiter, HeaderLimiter, MeteredLimiter, Rate, the provider presets |
capi_base_config | BaseConfig, CosignerCell, the auth config extensions |
capi_base_interface | BaseInterface, AuthScheme / AuthFlow / AuthEndpoint, Token / Basic and their flows, AuthTierInterface, Discoverable |
capi_base_decoders | BodyDecoder, PagedDecoder, SwitchDecoder, SseDecoder / EventDecoder, DocumentStreamDecoder, ChunkedDecoder, BytestreamDecoder, ResumableStreamDecoder / ResumeDownloadDecoder / StreamFnDecoder, FlatMapDecoder, ResponseBoundary |
capi_base_encoders | Multipart, Part, FileBlob |
capi_extras | The 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_headers | The wire-model HTTP header encoder |
capi_cookies (+ _macro) | CookieJar, Cookie, CookieMiddleware, CookieConfigExt, CookieSessionSource, HasCookieJar, cookie_auth |
capi_firewall | Firewall<Client>, EgressPolicy, Mode |
capi_debug_dump | DebugClient, the exchange dump in the canonical text format |
capi_notification | NotificationSender / NotificationReceiver, Notification / NotificationKind, LogNotification |
capi_context | ApiContext, ApiRng, ContextId, CancelToken, SendError, Refusal |
capi_bytestream | Bytestream, ContentLength, BodySource, StreamCodec, MaybeSend / MaybeSync |
capi_http_types | Method, StatusCode, Version, HeaderMap / HeaderName / HeaderValue, Request / Response and their heads, TrailerCell / TrailerState, Extensions, the url::Url a head carries, the text format |
capi_url | UrlBuilder, ToUrl, UrlError |
capi_time | ApiClock, Instant, SystemTime, sleep |
capi_lock | SyncMutex / SyncRwLock / SharedCell / SyncCondvar, AsyncMutex / AsyncRwLock |
capi_feature_state | assert_feature_alignment!, assert_lane_alignment!, the feature-state constants |
capi_derive | ApiEndpoint, capi, AuthScheme |
capi_macro_support | The macro-authoring toolkit: lane primitives, diagnostics, auth templates |
capi_test_core | MockClient / MockResponse / Fault, TestConfig / TestInterface, the mock clock and RNG, the pipeline test doubles |
The other workspaces
| Workspace | Members |
|---|---|
capi_wire | capi_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_auth | capi_auth_oauth2 (ConfigOAuth2Grant, Oauth2Flow, TokenEndpoint, Oauth2Refresher, discovery, PKCE), capi_auth_jwt (ConfigJwtAuth, JwtBearerFlow, JwtRefresher, JwtBuilder / JwtClaims / JwtSigningKey) |
capi_test_framework | capi_test_framework (ReplayClient, ReplayConfig, FixtureClass, FixtureAudit, QueryRedacted, the .ws session recorder), capi_test_macros (capi_test, development, strip_await) |
capi_engine | capi_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_graphql | capi_graphql (GraphqlCall, GraphqlApiConfig, Persisted, batching), capi_graphql_macros (graphql_schema! / graphql!), capi_graphql_schema (the host-only schema IR) |
Single-crate repos
| Repo | Kind |
|---|---|
capic_reqwest / capic_ureq / capic_wasm_fetch / capic_reqwless | client adapters — with capic_native_client above, the five in the capability table |
capiw_serde_json / capiw_urlencoded / capiw_quick_xml / capiw_prost | concrete codecs, each carrying its format’s conformance suite |
capiw_ext_json / capiw_ext_urlencoded (+ _macro) / capiw_ext_xml / capiw_ext_protobuf | format contracts: the Json, UrlForm, Xml, and protobuf markers and their attribute vocabularies |
capi_websocket | the WebSocket capability marker, RFC 6455 framing, TypedWsConnection, WsSession and the tunnel protocols, the split halves |
capi_grpc | unary, client-streaming, and server-streaming gRPC: Frame / FramedBody / MessageSource, GrpcDecoder / GrpcError / GrpcConfig, CompressionPolicy / MessageCoding, RequestMetadata / ResponseMetadata, GrpcStatus / GrpcCode |
capi_html_forms | FormFlow / FormFlowDecoder, HtmlForm, FormSelector, FieldUpdate / FormUpdate, HasUrlEncodedLib |
capi_http_batch | BatchConfig / ChangesetConfig, Batchable, BatchProfile, ArmedBatch, IntoBatch |
capie_gzip / capie_miniz_oxide / capie_brotli / capie_aws_chunked | content-coding StreamCodecs: gzip, deflate, br, aws-chunked; the first two also drive gRPC per-message compression |
capim_x402 | the x402 payment runner: X402 / X402Ext, RemoteAuthorizer, Terms / Requirement / Settlement |
capi_auth_aws_sigv4 / capi_auth_azure / capi_auth_digest | signers: Sigv4Signer / AwsCredentials, SharedKeySigner / AzureKeyCredential, DigestSigner / DigestCredentials |
capi_auth_google | Google service-account JWT and OAuth2 helpers |
capiw_conformance_tests | cross-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_rs — default = ["std", "derive"].
The three axes (the compilation target picks the platform; see Features and Targets):
| Feature | Effect |
|---|---|
std (default) | standard-library support on native and WASI; on browser wasm the platform-capability switch that enables each crate’s js bindings |
async | the async trait shapes; browser wasm always carries the async surface |
single-threaded | drops 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, andstdhas 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.