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.