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:
| Trait | Crate | What the config supplies |
|---|---|---|
HasAuthStore | capi_core | the credential store the stored-credential schemes write |
HasCookieJar | capi_cookies | the jar a cookie-login flow reads the session from |
GrpcConfig | capi_grpc | protobuf_codec(), and optional max_message_size, compression, grpc_headers |
BatchConfig / ChangesetConfig | capi_http_batch | the 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.