GraphQL
A GraphQL client library is readable source. Its operations are documented Rust
structs — the fields are the operation’s variables — beside the types the response
decodes into, and a schema module holds the service’s vocabulary: the scope
markers selections are written against, its enums, its input objects. That is the
library’s product, and it is the thing a user reads, greps, and jumps into.
capi_graphql provides the runtime those definitions plug into — a small family
of generic endpoints (GraphqlCall for the ordinary POST, Get, Persisted,
Uploads, Subscribe, GraphqlBatchCall, and GraphqlWsConnect), riding the
ordinary Http capability and the stock body decoder — plus the graphql! macro,
which is the consumer’s power tool rather than the library’s generator.
impl GraphqlOperation for GetUser {
type ApiConfig = MyConfig;
type Scope = schema::Query;
type Root = get_user::Root;
type Data = Option<get_user::User>;
const KIND: OperationType = OperationType::Query;
const NAME: &'static str = "GetUser";
fn variable_definitions() -> &'static [VariableDefinition] {
&[VariableDefinition { name: "id", graphql_type: "ID!" }]
}
fn selection() -> Selection<Self::Scope> { … }
fn extract(root: get_user::Root) -> Self::Data { root.user }
}
let user = api.query(GetUser { id: Id::new("1") }).await?;
What a client crate adds is small. Its config implements GraphqlApiConfig — two
methods, graphql_url() (the one absolute URL every operation posts to) and
json_lib() — and its root invokes graphql_schema! once:
capi_graphql::graphql_schema!(
schema = "schema.graphql", // the SDL, resolved against CARGO_MANIFEST_DIR
config = GraphqlZeroConfig,
schema_module = declared, // the crate hand-writes `pub mod schema`; `emitted` has the macro write it
);
scalars(DateTime = crate::scalars::DateTime) maps custom scalars (unmapped ones
fall back to String), and upload = Upload names the SDL scalar whose variables
carry files. That one invocation embeds the schema and exports the crate’s own
graphql!, which a consumer invokes from outside the crate to author an operation
the library never shipped, validated against the same schema at compile time:
graphqlzero_capi_rs::graphql! {
query AlbumTitle($id: ID!) {
album(id: $id) { title user { username } }
}
}
Whether a GraphQL service authenticates is a property of the deployment, not of an
operation, so every runtime endpoint declares Auth::DEFAULT.optional(): a config
that holds credentials installs a store and every call carries them, while an open
service sends bare, with no store at all. Every request sends
Accept: application/graphql-response+json, application/json.
The pieces, briefly:
- An operation value is its variables. The struct’s wire encoding is the
request’s
variablesobject, and the struct converts intoGraphqlCallthroughIntoEndpoint, soquery()takes it directly. Nullable variables and input-object fields areTristate<T>, keeping GraphQL’s absent/null/value distinction; a schema’s enums and input objects reach a document throughArgumentLiteral. - Decode types are named for the schema. They sit in a module named after
the operation, where
Rootis the decodeddataobject and every other type carries the name of the schema type it projects —get_user::User,get_user::Company— so the Rust reads like the document. Where one operation projects the same type twice, every projection of it takes the selection path that reached it instead. Libraries write this by hand;graphql!emits it. - The response envelope is decoded once, centrally:
{ data, errors }splits into the operation’s typed output or aGraphqlErrorResponsethat preserves any partial data alongside the spec’s error objects (message, locations, path,extensions). That envelope is every operation’sEndpoint::Error—GraphqlErrorResponse<Op::Data>, so a library writes no domain error enum — and a caller recovers it typed witherr.api_error::<GraphqlErrorResponse<Option<get_user::User>>>(), then reads.errors()and.data(). A non-success status without an envelope isUnexpectedStatusand a 2xx that isn’t one isDecoding: the standard ladder, with no per-library decoder code. graphql!is the downstream feature.graphql_schema!reads the SDL once per crate and exports the crate’sgraphql!wrapper with the schema embedded, so a consumer writes a selection the library never shipped — validated against that schema at compile time, with no code-generation step anywhere. Underschema_module = declaredthe macro emits no vocabulary of its own and resolves against the library’s hand-writtenschemamodule.- Schema drift is checked at test time, not compiled against. A library pins
an API vintage deliberately, so conformance belongs beside the fixtures:
projection::<Op>()reads back the requirements an operation’s code places on the schema — its selection, its variable declarations, the wire shapes of its types — andcheck_operationsverifies the committed SDL still satisfies them, reporting in the schema’s own language. The checker is theconformancefeature, a host-only test surface (the schema IR lives incapi_graphql_schema) that a client enables in its dev-dependencies and runs from a test. This is the consumer-driven-contract idea in GraphQL clothing: project what the code needs, check it against what the provider publishes. - Render equality holds the source to its intent. Each operation is declared
a second time through the crate’s own
graphql!, andoperation_documentproves the two send byte-identical documents — so a selection, alias, or argument that drifts from what was meant fails a test. - Queries can ride GET —
api.query(op.via_get())moves the document and variables into the percent-encoded query string and drops the body, so CDNs and browsers can cache the response. TheQueryOperationmarker is what keeps mutations off the GET transport at compile time. - Persisted queries (
apqfeature) —api.persisted().query(op)sends a SHA-256 of the document instead of the document, registering it exactly once on a cache miss: smaller requests on every call after the first, with the register-on-miss dance handled by a runner, above decoding, where the error envelope is already typed. - File uploads (
uploadsfeature) —Upload-typed variables travel as multipart file parts per the GraphQL multipart request spec, streamed on native transports; the request JSON rides beside them withnullat each file position, andapi.query(op)works unchanged. - Subscriptions —
subscriptionoperations run over GraphQL-over-SSE in distinct connections mode:api.query(op)returns a stream of the operation’s data type, eachnextevent decoding through the same envelope semantics as every other response, until the server completes. One root field per subscription. For graphql-transport-ws servers thewsfeature adds a WebSocket session, shaped by lane: on the async laneGraphqlWsSession::handshakethensplit()yields a caller-spawnedGraphqlWsDriverand aGraphqlWsHandlewhosesubscribereturns per-subscription typed streams demultiplexed by id over one connection; on the blocking laneGraphqlWsSubscription::openruns one subscription per connection with a blocking pump. The config’sGraphqlWsConfigsuppliesconnection_params(theconnection_initpayload, where a token rides) and agraphql_ws_urlthat defaults tographql_url. - Fragments and spreads compose selections, in the library’s source and
across
graphql!invocations alike; theScopeassociation is what proves a fragment belongs where it is spread. An interface selection without type conditions decodes as a plain struct of the interface’s declared fields; with conditions, common fields land in every variant of a__typename-tagged enum and uncovered concrete types decode into a typed common-fields fallback. Unions take inline fragments only. @batchrepeats one root field with per-entry inlined arguments underb0,b1, … aliases and restores entry order on decode — one request answering a whole set of lookups.
The reference client is graphqlzero_capi_rs; the authoring recipe lives in the
capi-rs-author skill (references/graphql.md).