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

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 (methodurlheadersauthacceptquery_stringcontent_typebodyrate_limitsigner), 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:

ReadFor
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(), not query(). query() is the call funnel on the interface; the endpoint method that builds the query string is query_string.
  • body(self, …) consumes self, 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 by query_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 an Option (defaults to None), a bool (false), or a Vec (empty). The constructor allows clippy::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 is impl Into<T>; a Vec<T> takes impl IntoIterator<Item = T> for a primitive T and impl IntoVec<T> (any iterator of items Into<T>) otherwise. The primitive match is syntactic, so an alias such as type Grams = u32; reads as non-primitive — mark the field #[capi(no_into)].
  • A getter per private field, named after the field.
  • A rustdoc # Parameters section 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:

locationGoes toConsumed by
"path"the URL path (via path_template placeholders)url() via self.path_string()
"query"the query stringquery_string() via self.query_fields()
"body"the request bodybody() via self.body_fields()
"header" (or location(header))a request headerheaders() 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>.rsendpoints/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 Endpoint with the five associated types and method + url, overriding only what varies;
  • one line of decoder wiring (impl DecodeBodyFn naming your shared decoder).

The next two chapters vary this: request bodies in every shape, then a cookbook of GET/POST/PATCH/DELETE templates.