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

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 for login() vs set_token() vs exchange(); they reach for authenticate and 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:

  1. 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.
  2. 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’s authenticate(...) is api.authenticate(...), and the handle derefs to BaseInterface in turn, so query and scoped are 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)] emits Deref/DerefMut to the struct’s field and the delegating ApiInterface / ProvidesConfig impls, 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 own Client parameter rather than pinning one. FollowUpChannel cannot 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.rs declares the credential’s placement.
  • auth.rs names the default scheme’s owning handle and re-exports the markers users reach.
  • interface.rs has your #[capi(interface)] wrapper whose one field is that handle (or BaseInterface directly, for a signing/no-auth client).
  • api.authenticate(…) and api.query(some_endpoint) compile.

Which scheme to reach for, and how each maps onto config, auth, and interface, is the next chapter.