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.