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

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 ApiConfig and 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 contractcapiw_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 carry unsafe, and an optional feature that swaps in a faster backend with unsafe inside 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 optional serde support; 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, and capi_http_types exist 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 — EntryRefresher and AuthSource, whose RefreshFuture and AuthSourceFuture aliases spell it — return Pin<Box<dyn Future + Send>> rather than async fn. Where a trait must be object-safe, the compiler cannot prove Send through a GAT projection in an async context, so a Tokio-movable future is hand-constructed and explicitly Send (rust-lang/rust#100013). Callers still .await; the Send bound drops away on the single-threaded lane and on browser wasm.
  • Additive features, target-selected platform. std switches surfaces on and never selects the platform; async flips trait shapes; single-threaded relaxes the bounds graph-wide. The compilation target picks the platform half, and the assert_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, or panic! in library code. Where a step seems to require one, the API is redesigned instead; where the stake is highest, a crate-root deny(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 redacting Debug and #[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.