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.