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

Endpoint Shapes: GET, POST, PATCH, DELETE, and Absolute-URL

A cookbook. Each entry is a complete, copy-adaptable template built on the contract — the differences between them are small and worth seeing side by side.

GET with path and query

Only method and url are required; a GET with query parameters adds query_string:

#[capi]
#[derive(Debug, Clone, WireModel, ApiEndpoint)]
#[endpoint(path_template = "/v1/places/{places_id}")]
pub struct GetPlaces {
    #[endpoint(location = "path")]  pub places_id: String,
    #[endpoint(location = "query")] pub language_code: Option<String>,
}
impl Endpoint for GetPlaces {
    type ApiConfig = GooglePlacesConfig;
    type Output = Place;
    type Error = GooglePlacesError;
    type Decoder = BodyDecoder;
    type Requires = Http;
    fn method(&self) -> Method { Method::GET }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        config.base_url().path(self.path_string())
    }
    fn query_string(&self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToQueryString, ParamsError> {
        config.form_lib().encode_qs(&self.query_fields())
    }
}
// + impl DecodeBodyFn { fn decode_fn() -> BodyFnDecoder<Self> { GooglePlacesResponse::decode } }

POST with a body

Add a body() that encodes the body fields — the archetype from the previous chapter. Nothing else changes.

PATCH with a sparse or tri-state body

A PATCH usually wants to touch only some fields. Two tools, from the type toolkit:

  • A plain Option<T> — a None field is omitted on encode and an absent field decodes as None; that is the wire model’s own rule, with no attribute needed.
  • #[wire(tristate)] over Tristate<T> — distinguishes omit (Keep), set (Set), and explicit null (Clear), which a PATCH that can clear a field needs:
#[endpoint(path_template = "/v1/custom-voices/{id}")]
pub struct PatchCustomVoice {
    #[endpoint(location = "path")] pub id: String,
    #[endpoint(location = "body")] #[wire(tristate)] pub name: Tristate<String>,
}
impl Endpoint for PatchCustomVoice {
    // ... method = PATCH ...
    fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        config.json_lib().encode_body(&self.body_fields())   // same encode path; the wire attr does the work
    }
}

DELETE and empty responses

Two shapes, depending on what the server returns:

  • No contentOutput = (), decoded by a helper that succeeds on 2xx and ladders errors otherwise (grok’s shared empty; S3’s decode_headers_only_with_headers for a headers-only reply). No body() override needed.
  • A confirmation body — some DELETEs return { "deleted": true }. Model Output = bool and unwrap it with DecodeWrappedBodyFn (below) rather than exposing the wrapper type.

Response-unwrap: Output is the inner type

When the wire body is a wrapper but you want your Output to be the inner value, implement DecodeWrappedBodyFn: decode the wrapper, return its inner field, and the wrapper type never appears in your public API.

impl DecodeWrappedBodyFn for AutocompletePlaces {
    type Wrapper = AutocompleteResponse;                 // the on-wire shape
    fn decode_wrapper_fn() -> WrappedBodyFnDecoder<Self, Self::Wrapper> {
        GooglePlacesResponse::decode                     // decode the wrapper; Output = its inner
    }
}

TryDecodeWrappedBodyFn is the fallible-unwrap variant when the inner extraction can fail.

Header-only responses

Some operations answer entirely in headers (S3’s HeadObject). Build the Output from Default and populate it from the parsed response headers. OutputHeaders and decode_headers_only_with_headers here are aws_s3’s own — a per-crate shape, not a framework trait — and the pattern is what to copy:

impl OutputHeaders for HeadObjectOutput {
    type Headers = HeadObjectHeaders;
    fn set_headers(&mut self, headers: HeadObjectHeaders) { /* copy fields in */ }
}
impl DecodeBodyFn for HeadObject {
    fn decode_fn() -> BodyFnDecoder<Self> {
        decode_headers_only_with_headers::<HeadObjectOutput, HeadObjectError>
    }
}

HEAD against any endpoint

Every shape so far is something you define. This one is something you do to an endpoint you already have.

Note the contrast with the section above: S3’s HeadObject is an endpoint, because S3 publishes it as an operation with its own headers and output type. What follows is different — a way to issue a HEAD against any endpoint, including ones whose service never advertised such a thing.

HeadRequest is an extension trait in capi_extras, and it arrives with the standard prelude:

use capi_rs::prelude::*;      // brings HeadRequest into scope

let head = api.head(&endpoint).await?;       // ResponseHead: status, version, headers
if head.status.is_success() {
    let value = api.query(endpoint).await?;  // the same endpoint, still yours to send
}

Notice what you did not write: no HeadGetPlaces type, no method() override, no second decoder, no change to the endpoint at all.

Under the hood head clones the endpoint into HeadOf<Endpt> — an ordinary adapter endpoint, in the prelude and constructible directly — whose method is HEAD and whose url, headers, auth, accept, query_string, rate_limit, and signer forward to the inner endpoint’s; body construction and Content-Type are skipped, both meaningless without a body. Its decoder, HeadDecoder, forwards the inner decoder’s prepare_request hook so the head carries the same specs, and resolves to the ResponseHead — status, version, and headers — with the body dropped unread, per HEAD semantics.

Two practical details:

  • The endpoint is borrowed, and cloned inside. head takes &Endpt and requires Endpt: Clone; the clone rides in HeadOf, so the original stays intact for the follow-up query. A non-Clone endpoint cannot be probed.
  • It runs through the chain’s runner. head accepts anything that opens a scoped request, so per-request overrides ride along and api.retry(spec).head(&endpoint) retries the probe.

The bounds are the same capability check query applies: the endpoint must be HTTP (Requires = Http) and the client must Supports it.

Caller-supplied absolute URL

For an endpoint that hits a URL handed to it — a pre-signed link, a next-page/download URL from a prior response — store the whole URL and return it verbatim from url(), and opt out of auth so a foreign host never sees your credential:

pub struct DownloadContent { pub url: String }
impl Endpoint for DownloadContent {
    fn url(&self, _config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
        self.url.clone()                 // returned as-is, not base_url().path(...)
    }
    fn auth(&self) -> Auth {
        Auth::NONE                   // don't send the credential off-host
    }
}

That Auth::NONE is the auth model opt-out in practice, and the absolute-URL shape is the foundation for binary downloads and cursor pagination that follow a server-provided link. capi_url accepts any scheme with an authority, so the URL is passed through as the service issued it.

Header parameters

A required header with a typed value is a location(header) field, and its consumption is one line in headers(). Google Places’ field mask is the shape — a list joined into one header value, type-checked against the response it selects:

#[capi]
#[derive(Debug, Clone, WireModel, ApiEndpoint)]
#[endpoint(path_template = "/v1/places/{places_id}")]
pub struct GetPlaces {
    #[endpoint(location = "path")]  pub places_id: String,
    #[endpoint(location = "query")] #[wire(rename = "languageCode", default)]
    pub language_code: Option<String>,
    #[endpoint(location(header))]   #[wire(rename = "x-goog-fieldmask", joined = ",")]
    pub field_mask: Vec<FieldMask<Place>>,
}

impl Endpoint for GetPlaces {
    // ...
    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())?,
        )
    }
}

A signed PUT with a streamed body

An upload against a signed service combines three shapes at once: a raw body, a declared signer, and a content type the endpoint owns. S3’s PutObject:

impl Endpoint for PutObject {
    type ApiConfig = AmazonS3Config;
    type Output = PutObjectOutput;
    type Error = PutObjectError;
    type Decoder = BodyDecoder;
    type Requires = Http;

    fn method(&self) -> Method { Method::PUT }
    fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl { /* bucket + key */ }
    fn auth(&self) -> Auth { Auth::DEFAULT.optional() }   // the key material the signer maps
    fn content_type(&self, _config: &Self::ApiConfig, _context: &ApiContext) -> impl Into<HeaderValue> {
        self.content_type.clone()                            // the stored object's own type
    }
    fn body(self, _config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
        let trailer = self.checksum.as_ref().and_then(ObjectChecksumSpec::trailer);
        streaming_body(self.body, trailer)                   // raw bytes; aws-chunked when a trailing checksum is declared
    }
    fn signer(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToSigner {
        config.service_signing()
    }
}

Auth::DEFAULT.optional() is what hands the SigV4 signer its key from the store — and lets an anonymous request through where a bucket policy allows one — while signer declares that the request is signed. The signing chapter has both halves.

Other things to do with an endpoint

Beside query and head, an endpoint value has two more verbs. finalize_request (capi_transport::Finalize, imported by name rather than from the prelude) completes the request’s construction — the pipeline’s request phases, credential placement, signing — and hands back the exact wire-form request without sending it, for a carrier that will put the request inside another message. And an capi_http_batch batch or changeset is itself an honest endpoint: arm it with the endpoints it carries and api.query(batch) sends the envelope and decodes each part with its own endpoint’s decoder. Both are the capture plane; the Batching chapter is the worked consumer.

Those are the everyday shapes. Advanced Response Patterns moves to the responses that need more than a single decoded value — streaming first.