Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The Five Ideas and the Request Lifecycle

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

Five ideas

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

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

The letter, the carrier, and the letterhead

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

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

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

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

Three layers

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

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

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

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

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

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

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

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

The core traits are the framework

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

The request lifecycle: a trip down a funnel and back

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

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

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

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

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

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

query() is the one funnel

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

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

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

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

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