auth.rs and interface.rs: Wiring the Entry Point
Every client has an entry point — the GrokApi / AmazonS3Api / IcecastConnect
type a user constructs and calls query() on. Building it takes two files that
work as a pair, and understanding why they’re two files is the key to reading
either.
Why authentication lives here
It’s worth understanding why the auth entry point is shaped the way it is, because the two-file split below is a direct consequence of it.
Authentication belongs to a service the same way the config does — it’s part of a service’s contract, not part of any one call. For a plain token that observation would be enough to keep auth in the config: a token is just a value you stash and attach later. But not every scheme is a stored value. OAuth2, a network token-exchange, a login flow — these have to make a request to obtain the credential in the first place, and making a request needs a client. The config has no client; the interface does — it owns the config and the client together. So auth can’t live purely in the config; it has to live at the interface level. That’s more machinery than a bare token would justify, but it’s the unavoidable cost of supporting schemes that reach the network.
Two design goals then shape how that machinery is exposed:
- A client library should never have to write async code. An endpoint is pure description, and the framework supplies both sync and async execution from that one definition. Auth that hits the network threatens that promise — a token-exchange needs a sync path and an async path — unless someone else writes both.
- There is one verb for authenticating, just as there’s one for querying. Every
credential goes in through
.authenticate(...), the same way every request goes out through.query(...). A user never has to hunt forlogin()vsset_token()vsexchange(); they reach forauthenticateand the type system tells them what it expects.
Those facts collide into one hard problem: different schemes need different
authenticate(...) signatures — a bearer token takes one string, basic auth takes
two, a token-exchange takes whatever its endpoint needs — yet each signature must
exist in both sync and async form, and none of it should require the client author to
write async plumbing. Capi’s answer is the auth scheme: a marker type that
names a flow handle, the struct whose inherent authenticate(...) has exactly the
parameters that scheme needs, written once by the scheme’s own crate in both lanes.
The framework ships the schemes for a credential handed in and stored (Token,
Basic); the auth add-ons ship the network ones (OAuth2AuthorizationCode,
JwtBearer, GoogleJwt, …); a client that mints through an endpoint of its own
derives one. A client picks a scheme. It writes no auth code.
Why two files
auth.rs describes the credential; interface.rs presents it. Concretely:
- In
auth.rs, you name the crate’s default scheme: a type alias for its owning flow handle —TokenFlow<BaseInterface<GrokConfig, Client>>for a bearer client — plus re-exports of the scheme markers your users reach. The module’s docs carry the user-facing auth story. - In
interface.rs, your public API struct stores that handle in its one field and carries#[capi(interface)], which makes the struct be the interface it wraps: it derefs to the handle, so the handle’sauthenticate(...)isapi.authenticate(...), and the handle derefs toBaseInterfacein turn, soqueryandscopedare there too.
Here’s the whole loop for a bearer client.
config.rs: declare the placement
config: AuthConfigExt::bearer(CookieConfigExt::new(config)),
The config says where the credential rides — Authorization: Bearer … here. Use
AuthConfigExt::new_with_auth_header(config, HeaderName::from_static("x-goog-api-key"))
for an API key sent bare in its own header, and
AuthConfigExt::with_placement(config, header, prefix) for anything else. A token
handed in later carries no scheme of its own; the store dresses it.
The config also says it has a store, beside its auth_store() override:
impl HasAuthStore for GrokConfig {
fn credential_store(&self) -> AuthStore {
self.config.auth_store()
}
}
ApiConfig::auth_store stays optional, because not every API needs a credential;
HasAuthStore is the capability the stored-credential schemes bound on, which is
why api.authenticate("xai-…") returns nothing — with the store guaranteed, the
write cannot fail.
auth.rs: the default scheme
//! Authentication for the Grok API.
//! (the user-facing auth story: which calls store which credential)
use capi_rs::prelude::*;
pub use capi_rs::interface::{Token, TokenFlow};
/// The crate's default scheme: a stored token, owning the interface it
/// authenticates against.
pub type GrokAuth<Client> = TokenFlow<BaseInterface<GrokConfig, Client>>;
Token is the scheme; TokenFlow<I> is its handle, generic over how it holds
the interface — by value here, because the API type owns it. That’s all auth.rs
is: the alias, the re-exports, and the prose. The strategies chapter
is the full taxonomy of schemes to name here.
The default scheme need not be a stored token. A service whose primary
credential is minted over the network names that flow’s handle instead — the
ringcentral client’s default is the OAuth2 JWT-bearer exchange, so
api.authenticate(client_id, client_secret, jwt) is the exchange:
pub type RingCentralAuth<Client> =
Oauth2Flow<BaseInterface<RingCentralConfig, Client>, OAuth2JwtBearer>;
// interface.rs — the constructor wraps the interface in that handle:
inner: BaseInterface::new(config, client).into_flow::<OAuth2JwtBearer>(),
into_flow::<S>() is the by-value counterpart of flow::<S>(): it hands the
interface to the scheme’s handle instead of lending it.
interface.rs: the wrapper
#[capi(interface)]
#[derive(Debug, Clone)]
pub struct GrokApi<Client>
where
Client: ApiClient,
{
inner: GrokAuth<Client>,
}
impl<Client: ApiClient> GrokApi<Client> {
pub fn new(client: Client, json_lib: Codec<Json>, form_lib: Codec<UrlForm>) -> Self {
Self {
inner: BaseInterface::new(GrokConfig::new(json_lib, form_lib), client)
.into_flow::<Token>(),
}
}
}
Two things, each doing one job:
#[capi(interface)]emitsDeref/DerefMutto the struct’s field and the delegatingApiInterface/ProvidesConfigimpls, so the API type can also be passed where a signature asks for&impl ApiInterface<Config>.<Client>stays generic, with no default. That is how one interface definition serves every transport the family offers — native, blocking, browser-wasm — and how a downstream app stays backend-agnostic in turn: it keeps its ownClientparameter rather than pinning one.FollowUpChannelcannot fill this seat; it is a send channel, not a client.
A user now writes GrokApi::new_with_defaults(client), api.authenticate("xai-…"),
and api.query(endpoint), and rustdoc lists authenticate on GrokApi’s own page
under the methods reached through Deref.
The Deref chain
Here is the mechanism that makes both authenticate() and query() resolve.
query and scoped are inherent methods on BaseInterface; authenticate is an
inherent method on the scheme’s handle. Your wrapper reaches both through a chain of
Derefs:
GrokApi ──Deref──▶ TokenFlow<BaseInterface<…>> ──Deref──▶ BaseInterface ──Deref──▶ GrokConfig
(wrapper) (the default scheme's handle) (query/scoped/flow) (base_url, …)
#[capi(interface)] emits the first Deref; every flow handle carries
the second as part of its plumbing. A client with no scheme of its own — because
its auth is signing-based (SigV4) or there’s no auth at all —
stores BaseInterface directly, and the attribute emits the same Deref to it:
#[capi(interface)]
#[derive(Debug, Clone)]
pub struct AmazonS3Api<Client>
where Client: ApiClient {
inner: BaseInterface<AmazonS3Config, Client>,
}
impl<Client: ApiClient> AmazonS3Api<Client> {
// hand-written, because signing installs credentials rather than storing a header:
pub fn authenticate(&self, access_key_id: impl Into<String>, /* … */) {
self.inner.config().set_credentials(AwsCredentials::new(/* … */), region);
}
}
Every other scheme: flow::<S>()
The default scheme is the one on the API type. Every other scheme is reached through
flow, an inherent method of BaseInterface (so it is on the API type through the
chain above) that builds the borrowing handle for one call:
api.flow::<GoogleJwt>().authenticate_from_bytes(key_json, scope).await?.auto_refresh();
api.flow::<OAuth2AuthorizationCode>().authenticate(client_id, secret, handler).await?.auto_refresh();
A scheme is a type, so the flows a client supports are the markers its auth
module re-exports, gated by the features that bring their add-on in. Nothing is
generated per flow, and the call shape is the same for every scheme.
Named tiers
Named tiers solve a service with privileged scopes. grok’s management endpoints require a different API key from ordinary calls, and a named tier lets the framework keep that key in its own slot and select it for exactly those requests. A tier is chosen where the handle is built:
api.as_role("management").flow::<Token>().authenticate("xai-…");
fills the Named("management") slot, while endpoints on the management host
override auth() to Auth::named("management") to read from it. The same shape
serves a network scheme at a tier: api.as_role("admin").flow::<JwtBearer>(). A
client may wrap the common one in a one-line accessor, for discoverability and so
the tier name has one home:
pub fn management(&self) -> TokenFlow<&BaseInterface<GrokConfig, Client>> {
self.as_role(MANAGEMENT_AUTH_TIER).flow::<Token>()
}
No I/O happens in the accessor, so there is no lane split to write.
A tier handle is also a place to send from. api.as_role("management") yields
an AuthTierInterface: every request sent through its query(endpoint) that
asked for the default credential reads the management tier instead, so an
endpoint that does not name its tier can still be run as that role. The handle
carries the plain writes too — .set_token(token) and .clear() — and a
generic mint, .authenticate(endpoint), which sends the endpoint and stores its
decoded token under the tier; the minting request itself carries no
caller-supplied authorization, because the credential it exists to produce is
the one it would otherwise be asked to present.
You’re done when
config.rsdeclares the credential’s placement.auth.rsnames the default scheme’s owning handle and re-exports the markers users reach.interface.rshas your#[capi(interface)]wrapper whose one field is that handle (orBaseInterfacedirectly, for a signing/no-auth client).api.authenticate(…)andapi.query(some_endpoint)compile.
Which scheme to reach for, and how each maps onto config, auth, and interface, is the next chapter.