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>— aNonefield is omitted on encode and an absent field decodes asNone; that is the wire model’s own rule, with no attribute needed. #[wire(tristate)]overTristate<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 content —
Output = (), decoded by a helper that succeeds on 2xx and ladders errors otherwise (grok’s sharedempty; S3’sdecode_headers_only_with_headersfor a headers-only reply). Nobody()override needed. - A confirmation body — some DELETEs return
{ "deleted": true }. ModelOutput = booland unwrap it withDecodeWrappedBodyFn(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.
headtakes&Endptand requiresEndpt: Clone; the clone rides inHeadOf, so the original stays intact for the follow-upquery. A non-Cloneendpoint cannot be probed. - It runs through the chain’s runner.
headaccepts anything that opens a scoped request, so per-request overrides ride along andapi.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.