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 Decoupling Thesis

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

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

The core idea

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

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

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

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

The pipeline

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

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

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

What you get

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

Honest tradeoffs

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

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

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

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