The Endpoint Contract
Here’s the payoff of everything so far: with a config, auth, errors, decoders, and
types in place, an endpoint is a struct + two macros + one impl Endpoint + one
line of decoder wiring. Every endpoint in the rest of the book is a variation on
this one shape, so learn it once here.
The archetype
This is the skeleton every JSON endpoint follows — the shape of grok’s Chat Completions endpoint, with the path template and the derive line in the form the reference clients use:
#[capi] // constructor + setters
#[derive(Debug, Clone, WireModel, ApiEndpoint)] // wire attributes + field-location routing
#[endpoint(path_template = "/v1/chat/completions")]
pub struct PostChatCompletions {
#[endpoint(location = "body")] pub model: String,
#[endpoint(location = "body")] pub messages: Vec<Message>,
// ...
}
impl Endpoint for PostChatCompletions {
type ApiConfig = GrokConfig;
type Output = ChatResponse;
type Error = GrokError;
type Decoder = BodyDecoder;
type Requires = Http;
fn method(&self) -> Method { Method::POST }
fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
config.base_url().path(self.path_string())
}
fn content_type(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue> {
"application/json"
}
fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
config.json_lib().encode_body(&self.body_fields())
}
}
impl DecodeBodyFn for PostChatCompletions {
fn decode_fn() -> BodyFnDecoder<Self> { GrokResponse::decode }
}
Five lines of associated types, two required methods, one body method, one decoder line. That’s the whole contract.
The Endpoint trait
pub trait Endpoint {
type ApiConfig: ApiConfig; // which config this endpoint binds to
type Output: Unpin + MaybeSend + 'static; // the decoded success type
type Error: StdError; // your modeled domain error (Modeling Your Domain Error)
type Decoder: ResponseDecoder<Self>; // the decoder marker (The Decoder Contract and the Status Ladder)
type Requires: Capability; // Http, WebSocket, Grpc
fn method(&self) -> Method; // required
fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl; // required
// provided (override as needed):
fn headers(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<HeaderMap, HttpHeaderError>;
fn auth(&self) -> Auth;
fn accept(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue>;
fn query_string(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToQueryString, ParamsError>;
fn content_type(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue>;
fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError>;
fn rate_limit(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToRateLimiter;
fn signer(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToSigner;
}
Only method and url are required; the rest have defaults. Implement members in
declaration order (method → url → headers → auth → accept →
query_string → content_type → body → rate_limit → signer), omitting the
ones you don’t override. The Decoder: ResponseDecoder<Self> bound is what turns
a mismatch between the marker’s output and Output into an error at the
declaration site; an endpoint that decodes its own responses declares
type Decoder = Self and implements ResponseDecoder itself (the decoder system
in depth).
The (config, context) pair
Every construction method takes both. The config is what is true of every request this client makes: the base URL, the codecs, the default limiter. The context is scoped to the one request being built, and it is where the per-request facts live:
| Read | For |
|---|---|
context.id() | echoing the framework’s own context identity into a header |
context.rng() | a multipart boundary, an idempotency key — this request’s own forked stream, so both replay |
context.clock() | a timestamp in a payload; pair with replay_system_time(name) to make it reproduce |
context.get_extension::<T>() | anything the caller attached for this request |
It is the same pair the decoder’s prepare_request and decode and the seal
receive — a pipeline member gets the context alone — so everything that observes a
request sees it the same way.
ApiConfig::base_context() is a different thing and reading it here is almost
always a mistake: it is the template request contexts are derived from, with
no context ID and a stream every request on the client shares. Configure it
(clock, RNG, notifications) and every request inherits the setup; read this
request’s state from the context your method was handed.
Three gotchas worth a callout
- It’s
query_string(), notquery().query()is the call funnel on the interface; the endpoint method that builds the query string isquery_string. body(self, …)consumesself, and runs last. It takes the endpoint by value (the others take&self), so it’s the final method the framework calls.url()must be absolute and exclude the query string. Return the full URL; the framework appends the query string built byquery_string()separately.
query() is the one funnel
There is no get() / post() / download(). The endpoint’s type carries the
method, body, output, and decoder, and one call runs it:
let response = api.query(PostChatCompletions::new(model).messages(msgs)).await?;
This is why switching an output is just handing query()
a different type — there’s no parallel method surface to keep in sync. The same
call has a second spelling, endpoint.query(&api), and a few relatives that still
run the one path: api.scoped() for per-request overrides, api.head(&endpoint) to
probe an endpoint’s headers without its body, and finalize_request to build the
request without sending it (endpoint shapes).
The two derives
An endpoint carries two derives, and each owns one attribute namespace.
#[derive(WireModel)] owns #[wire(...)]: it makes the wire attributes on the
endpoint’s fields legal, gives every field the omit-on-None presence rule and the
rest of the wire vocabulary, and emits the Redact
impl that lifts onto Capi’s ApiRedact — so endpoint.clone().redacted()
masks every plane at once, path fields included, and a fixture can hold a request
that never held a secret (Value Classes and Redaction).
#[derive(ApiEndpoint)] owns #[endpoint(...)]: it reads each field’s location and
generates the proxies, forwarding any #[wire(...)] it finds onto them without
parsing it. An endpoint with no wire-attributed fields compiles with ApiEndpoint
alone; the reference clients derive both on every endpoint, and this book does too.
What #[capi] generates
#[capi] is the constructor-and-setters macro, and it is deliberately dumb
so it never fights the wire mapping. It emits:
new(...), taking every required field as a positional parameter — a field is required unless it is anOption(defaults toNone), abool(false), or aVec(empty). The constructor allowsclippy::too_many_arguments, since required fields map straight to parameters.- A setter per field, consuming and returning
self. Primitives — the integer and float types,bool,char— are taken as themselves, so a bare literal infers; every other type isimpl Into<T>; aVec<T>takesimpl IntoIterator<Item = T>for a primitiveTandimpl IntoVec<T>(any iterator of itemsInto<T>) otherwise. The primitive match is syntactic, so an alias such astype Grams = u32;reads as non-primitive — mark the field#[capi(no_into)]. - A getter per private field, named after the field.
- A rustdoc
# Parameterssection on the struct, one line per field, carrying the wire contract the field’s attributes declare.
Container-level #[capi(...)] beside the macro sets defaults for every setter —
no_into, no_strip_option (Option<T> setters take Option<T>), borrow_self,
bool (parameterless true setters), prefix = "set_", the generate* switches,
and generate_delegates(..) for a parallel setter block on a wrapper type — and
field-level #[capi(skip | generate | rename = ".." | no_into | doc = "..")]
overrides them for one field. #[capi(no_setters)] and no_new drop either
half; #[capi(interface)] is the other mode entirely, the one the
wiring chapter uses on the API type. Enums are not supported:
model a string-valued enum with #[wire(string_enum)].
Field-location routing
#[derive(ApiEndpoint)] reads a #[endpoint(location = …)] on each field and
generates proxy accessors your impl Endpoint consumes. It registers only the
endpoint attribute; the #[wire(...)] attributes a field may carry belong to
WireModel, which is why the two are always derived together — the endpoint
derive forwards #[wire] onto the proxies it generates and never parses it. The locations:
location | Goes to | Consumed by |
|---|---|---|
"path" | the URL path (via path_template placeholders) | url() via self.path_string() |
"query" | the query string | query_string() via self.query_fields() |
"body" | the request body | body() via self.body_fields() |
"header" (or location(header)) | a request header | headers() via self.header_fields() |
"skip" | nowhere (local-only field) | — |
So a mixed endpoint wires each proxy to the matching codec:
fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
config.base_url().path(self.path_string()) // path fields
}
fn query_string(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToQueryString, ParamsError> {
config.form_lib().encode_qs(&self.query_fields()) // query fields → urlencoded
}
fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
config.json_lib().encode_body(&self.body_fields()) // body fields → JSON
}
path_template = "/v1/places/{places_id}" names the placeholder a location = "path"
field fills; a placeholder-free template yields a &'static str from
path_string(). Header fields are consumed through the header plane’s encoder, the
one line the table above elides:
fn headers(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<HeaderMap, HttpHeaderError> {
config.headers().try_merge_with(
capi_rs::http_headers::WireHeaders.encode(&self.header_fields())?,
)
}
Per-field #[wire(rename = …, joined = ",", …)] controls the wire names, exactly as
in the type toolkit. (Both location = "header" and
location(header) parse; the paren form is common in the reference crates.) Three
container options round out #[endpoint(...)]: default_location = "path" | "query" | "body" for fields that name none, body_envelope = "key" to nest the body proxy
under one wire key (a field opts out with #[endpoint(body_envelope = false)]), and
location = "none" (or "skip") for a field that belongs to no plane.
Module organization
The convention is one file per operation, under
endpoints/<resource>/<verb>.rs — endpoints/chat/completions/post.rs,
endpoints/places/places/get.rs. Each file holds one endpoint struct and its impls, so the
module tree mirrors the API’s own resource/verb shape and an endpoint is trivial to
find.
Checklist
An endpoint is done when it has:
- a struct with
#[capi]+#[derive(WireModel, ApiEndpoint)]+#[endpoint(path_template = …)]; -
#[endpoint(location = …)]on every field that goes on the wire; -
impl Endpointwith the five associated types andmethod+url, overriding only what varies; - one line of decoder wiring (
impl DecodeBodyFnnaming your shared decoder).
The next two chapters vary this: request bodies in every shape, then a cookbook of GET/POST/PATCH/DELETE templates.