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 Config: A Service’s Contract

The config is the most important file in a client. Think of it as the contract for one API service. A GrokConfig describes everything the Grok endpoints expect — the base URL they hang off, the default headers they carry, the codecs their bodies are written in, the auth state their calls need, a cookie jar if the service happens to sit behind Cloudflare. When you stand up a config you are standing one up for a service, and that service’s endpoints are bound to it by type. Because so much of that contract is identical for every call, the config doubles as the home for shared state — which is why an endpoint can carry only what varies. This chapter builds config.rs from the inside out.

Why endpoints are bound to a config

The Endpoint trait carries an ApiConfig associated type, and it has to match: a Grok endpoint declares type ApiConfig = GrokConfig, so it can only be issued through a Grok config — the endpoint literally needs that config to construct the request. That binding does two useful things beyond wiring.

It keeps endpoints from reaching the wrong service. The same Capi internals can back several client libraries in one binary, and structurally every endpoint is just an endpoint — nothing about the bare types stops google_client.query(grok_endpoint) from looking reasonable. The associated type is what stops it: an endpoint whose ApiConfig is GrokConfig will not go out through a Google interface, so a request meant for one service can’t be misdirected to another at the wrong URL.

And that same guard is a tool downstream users can pick up. Normally, when an API changes and a client library falls behind, you wait for the maintainer to publish a fix. Here you don’t have to. Because an endpoint is an ordinary type bound to a config, anyone can recreate a broken Grok endpoint with the correction and associate it with the original GrokConfig — and it drops straight into the existing client with the same auth, codecs, and pipeline as everything else. That single property is what makes Capi clients patchable in the field, and it’s the door to more advanced moves: overriding an endpoint’s decoding, or using IntoEndpoint to substitute your own input and output types for the built-in ones. Those get their own treatment later.

The config-vs-endpoint test

When you’re unsure whether something belongs on the config or the endpoint, ask: would this be identical for every call to this API? Base URL, default headers, the codecs, the auth state, the pipeline members, the default rate limiter — yes, those are config. A path parameter, a request body, a query value — no, those are the endpoint. The config holds the service-wide contract; the endpoint holds the one request.

The onion

Nothing in the framework requires you to build a config out of the pieces below. BaseConfig, CookieConfigExt, AuthConfigExt, and the rest are pre-built blocks — a config is anything that implements ApiConfig, and you could satisfy that trait by hand. What these blocks buy you is the boilerplate: BaseConfig already holds the base URL, the default headers, the rate limiter, the base context, the user agent, and the cosigner cell that almost every service needs, so instead of re-declaring all of that you wrap it and expose it through Deref. This is the building-block idea from the crate map applied to configuration — you assemble a new service client by picking the layers that match the service and letting each one handle only its own concern.

A config is built by wrapping BaseConfig in those capability layers, each an …ConfigExt<Inner>, and exposing the whole stack through Deref:

pub struct GrokConfig {
    config: AuthConfigExt<CookieConfigExt<BaseConfig>>,
    management_url: UrlBuilder,
    json: Codec<Json>,
    form: Codec<UrlForm>,
}

Read the type inside-out: BaseConfig (base URL, headers, rate limiter, base context) is wrapped by CookieConfigExt (a cookie jar; it needs the facade’s cookies feature) which is wrapped by AuthConfigExt (the credential store). Each layer adds a capability and forwards the rest. You reach the whole stack by implementing Deref/DerefMut to the onion:

impl Deref for GrokConfig {
    type Target = AuthConfigExt<CookieConfigExt<BaseConfig>>;
    fn deref(&self) -> &Self::Target { &self.config }
}
impl DerefMut for GrokConfig {
    fn deref_mut(&mut self) -> &mut Self::Target { &mut self.config }
}

That Deref is what gives your config base_url(), auth_store() (the store itself, whose set_auth/set_token write the credentials), cookie_jar() (behind the facade’s cookies-jar-access feature), and the rest without you re-declaring them. Each layer owns exactly one concern — CookieConfigExt carries the cookie jar and seats its member, AuthConfigExt the credential store — and forwards everything else down the chain. When the cookie jar is the credential, build both layers in one call: CookieConfigExt::new(base).session_auth("sessionid") returns exactly this shape, with a store that resolves a tier from that cookie’s presence — so endpoints behind the session keep Auth::DEFAULT rather than claiming they need no auth. Only the layers your client needs go in the onion; the codec-free icecast config below is just AuthConfigExt<BaseConfig>.

Because the layers are reached through Deref rather than a fixed shape, a consumer of a layer needs a way to find it in whatever onion you built. That’s what the small trait impls in the next section are for — they point the framework (and third-party crates) at the right layer. Most layers are conveniences you take or leave, but a few are effectively load-bearing: the auth layer in particular is something a number of other crates expect to be present and reachable, so if a service authenticates at all, exposing its auth store correctly (via the auth_store() override and the HasAuthStore impl, below) is not optional.

Typed codec handles

The codecs are stored as Codec<Format> fields and exposed through hand-written accessors — by convention json_lib(), form_lib(), xml_lib():

impl GrokConfig {
    pub fn json_lib(&self) -> Codec<Json>    { self.json }
    pub fn form_lib(&self) -> Codec<UrlForm> { self.form }
}

These are per-config, not methods on the core ApiConfig trait — a JSON-only client has no reason to answer xml_lib(). Endpoint and decoder code reads them at the point of use: config.json_lib().encode_body(...), config.json_lib().decode_from_slice(...).

The two constructors

Provide two ways to build the config — the swappable one and the convenient one:

impl GrokConfig {
    /// Batteries-included: the default codecs, behind the `default-codecs` feature.
    #[cfg(feature = "default-codecs")]
    pub fn new_with_defaults() -> Self {
        Self::new(capiw_serde_json::codec(), capiw_urlencoded::codec())
    }

    /// Explicit codecs — the swap point for a consumer who wants a different engine.
    pub fn new(json_lib: Codec<Json>, form_urlencoded_lib: Codec<UrlForm>) -> Self {
        let config = BaseConfig::new(DEFAULT_BASE_URL).with_user_agent(DEFAULT_USER_AGENT);
        Self {
            config: AuthConfigExt::bearer(CookieConfigExt::new(config)),
            management_url: MANAGEMENT_BASE_URL,
            json: json_lib,
            form: form_urlencoded_lib,
        }
    }
}

Note BaseConfig::new takes a UrlBuilder, not a &str — build the base URL with UrlBuilder::https("api.x.ai") (usually a const), then chain builders like .with_user_agent(...). new_with_defaults() exists only when default-codecs is on; new(...) is always available and is what makes the JSON engine swappable.

The required trait impls

Two traits make the config usable, both just forwarding into the onion; a cookie-login config adds a third, HasCookieJar, which the AuthConfigExt<CookieConfigExt<_>> shape satisfies through a blanket impl and a hand-rolled outer config forwards in one line.

ApiConfig is the core contract the framework reads. base_context returns the template every request context is derived from — configure its clock, RNG and notifications here and each request inherits them:

impl ApiConfig for GrokConfig {
    fn base_context(&self) -> ApiContext { self.config.base_context() }
    fn headers(&self) -> HeaderMap { self.config.headers() }
    fn middleware(&self) -> Pipeline { self.config.middleware() }
    fn default_rate_limiter(&self) -> impl ToRateLimiter { self.config.default_rate_limiter() }
    fn cosigner(&self) -> impl ToCosigner { self.config.cosigner() }
    fn user_agent(&self) -> Option<HeaderValue> { self.config.user_agent() }
}

base_context and user_agent are required; headers, middleware, default_rate_limiter, and cosigner have defaults, and a wrapper forwards them anyway so the inner layers’ members and settings apply. cosigner() is the one whose omission is silent: ApiConfig defaults it to NO_COSIGNING, while BaseConfig answers it from its cosigner cell — so a wrapper that leaves the default in place turns config.set_cosigner(..) into a no-op, with nothing to say so.

ApiConfig::auth_store exposes the credential store to the seal, which places the credentials it holds; it is optional because not every API authenticates. A config that does authenticate also implements HasAuthStore with the same store — the capability the stored-credential schemes bound on, and the reason .authenticate(...) cannot fail:

impl ApiConfig for GrokConfig {
    // …
    fn auth_store(&self) -> Option<AuthStore> { Some(self.config.auth_store()) }
}

impl HasAuthStore for GrokConfig {
    fn credential_store(&self) -> AuthStore { self.config.auth_store() }
}

Defaults: the pipeline and rate limiting

middleware() and default_rate_limiter() usually forward straight to the onion, but this is where you set client-wide defaults. A config that wants a correlation id on every request seats one member on the forwarded pipeline:

fn middleware(&self) -> Pipeline {
    self.inner
        .middleware()
        .with_request(Phase::Early, InjectRequestId::new("x-request-id"))
}

The phase decides when the member runs relative to the others; Middleware covers the four phases and the three member traits; InjectRequestId lives at capi_rs::extras::middleware::InjectRequestId, outside the prelude. For a default rate limit, override default_rate_limiter() (note the name — it’s with_default_rate_limit / default_rate_limiter, there is no with_rate_limiter); see Rate Limiting.

Capability traits a config opts into

HasAuthStore is one instance of a pattern: a service opts into a capability by implementing a : ApiConfig trait on its config, and the endpoints that need the capability bound on it. The shipped ones:

TraitCrateWhat the config supplies
HasAuthStorecapi_corethe credential store the stored-credential schemes write
HasCookieJarcapi_cookiesthe jar a cookie-login flow reads the session from
GrpcConfigcapi_grpcprotobuf_codec(), and optional max_message_size, compression, grpc_headers
BatchConfig / ChangesetConfigcapi_http_batchthe service’s batch endpoint URL, from the config’s own host knowledge

A gRPC client stores a Codec<Protobuf> under its grpc feature and answers protobuf_codec() from it; an OData client answers ChangesetConfig with its $batch URL. Each trait is the whole contract between the add-on and the service’s config.

Worked variants

The same skeleton absorbs every auth and format shape. Four contrasts:

Codec-free (icecast). A client speaking a binary protocol stores no codecs at all — the onion is just AuthConfigExt<BaseConfig>, there are no json_lib() accessors, and new() takes no codec arguments. Proof that the wire model is opt-out.

Multi-format + signing (aws_s3). Stores three codecs, the credential store, and the credential-free signer:

pub struct AmazonS3Config {
    config: BaseConfig,
    form: Codec<UrlForm>,
    xml: Codec<Xml>,
    json: Codec<Json>,
    auth: AuthStore,
    sigv4: SharedCell<Option<Sigv4Signer>>,
    // ...
}
// new_with_defaults() wires capiw_quick_xml + capiw_urlencoded + capiw_serde_json;
// new(xml_lib, form_lib, json_lib) takes all three explicitly.

and publishes a snapshot of it for its endpoints to declare:

pub fn service_signing(&self) -> Signing {
    self.sigv4.with(|s| s.clone().map(Signing::new).unwrap_or(NO_SIGNING))
}

The service scheme is typed config state, not an ApiConfig method — each signed endpoint returns config.service_signing() from Endpoint::signer, so whether a request is signed is readable off the endpoint. The signer itself is installed at runtime by the API’s authenticate(...) — see Request Signers and Presigning.

API-key-in-custom-header (google_places). Instead of an Authorization header, the key rides in a vendor header. AuthConfigExt::new_with_auth_header sets that up:

config: capi_auth_google::GoogleAuthConfigExt::new(
    AuthConfigExt::new_with_auth_header(config, HeaderName::from_static("x-goog-api-key")),
    json_lib,
    form_urlencoded_lib,
),

Here the onion has a vendor layer too (GoogleAuthConfigExt, from the dedicated capi_auth_google crate), wrapping the standard AuthConfigExt.

Canonical JSON (grok). The GrokConfig shown throughout this chapter — a JSON body codec, a urlencoded query codec, bearer auth, cookies. This is the shape most clients start from.

Base URL as a parameter (generic_openai). A client for an API served by many providers takes the base URL in its constructor instead of a constant:

pub fn new(base_url: UrlBuilder, json_lib: Codec<Json>) -> Self {
    let config = BaseConfig::new(base_url).with_user_agent(DEFAULT_USER_AGENT);
    // ...
}

with new_with_defaults(base_url) beside it under default-codecs.

Every one of these is the same three moves: a struct wrapping an onion plus typed codec fields, two constructors, and the ApiConfig impl (with cosigner() forwarded) plus its auth_store() override and HasAuthStore beside it. With the config standing, Authentication wires up authentication.