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

Request Bodies: JSON, Forms, Raw Bytes, and Multipart

body(self, config, context) is the one endpoint method that consumes the endpoint, and it returns anything that implements ToBody. That single return type is why the same method covers a JSON object, a raw file, a multipart upload, and a policy-signed form — they are all different ToBody values. This chapter walks the range, then covers the Bytestream primitive underneath all of them.

ToBody and the encode call

body() returns impl ToBody — a sealed trait with one method, to_body(self) -> Result<RequestBody, BodyError>, blanket-implemented for anything that converts into a Bytestream (TryInto<Bytestream>, with the conversion’s error folding into BodyError). RequestBody has two arms: Ready(PreparedBody), which every ordinary body takes, and PolicySigned { signing, body }, for a body whose bytes cannot exist until a policy is signed — the signing chapter covers that one. You rarely construct a PreparedBody by hand; the codec does it. A JSON body is one line:

fn body(self, config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
    config.json_lib().encode_body(&self.body_fields())   // → EncodedBody, which is ToBody
}

A URL-encoded form body is the same line with form_lib(). encode_body returns an EncodedBody carrying the bytes and a BodySpec — the codec and the type’s shape, which fixture tooling reads to decode the body structurally — and it implements ToBody, so there is no manual Bytestream wrapping at the call site.

encode_body vs encode_to_vec. Different methods, different jobs. encode_body(&value) is the request-body encoder: it returns an EncodedBody ready to be a body, spec included. encode_to_vec(&value) returns a bare Vec<u8> and is for the streaming and WebSocket paths that hand raw bytes elsewhere. In an endpoint body(), you want encode_body.

For a genuinely empty body, return EMPTY_BODY (a fresh empty Bytestream). Content type precedes as PreparedBody’s own content type (a multipart body knows its boundary) > the endpoint’s content_type() > none, and Content-Length is set only when the body knows its exact length — an empty body sends neither header, so a GET stays bare.

Raw and binary bodies: FileBlob’s two paths

The highest-consequence body decision is how a binary payload travels, because FileBlob has two paths and they produce completely different wire bytes:

  • Verbatim binary — a FileBlob field with no location attribute, returned straight from body():

    #[capi]
    pub struct PutObject { pub body: FileBlob /* no #[endpoint(location=...)] */ }
    impl Endpoint for PutObject {
        fn body(self, _config: &Self::ApiConfig, _context: &ApiContext) -> Result<impl ToBody, BodyError> {
            Ok(self.body)   // FileBlob: ToBody streams the raw bytes
        }
    }
  • Base64 inside a JSON body — a FileBlob as a location = "body" scalar field encodes as a base64 / data-URL string (it’s a WireScalar), so it rides inside the JSON object like any other field.

Same type, opposite results: a bare ToBody field streams the raw bytes; a body-scalar field base64-encodes them into JSON. Choosing wrong sends base64 text where the server expects octets, or vice versa. This is the one body footgun to internalize. A FileBlob is backed by bytes, a filesystem path, or a browser blob; the file-backed form classifies as replayable, so a retry or redirect re-reads the file rather than losing it.

Multipart

A multipart endpoint sets content_type() to "multipart/form-data" and builds the body from field-level #[endpoint(location = "body", multipart(...))] annotations, in declaration order. location = "body" is required: multipart resolution runs over the body fields, and a field that names no location belongs to no plane. grok’s file upload shows the four roles:

#[endpoint(location = "body", multipart(name = "purpose"))]
pub purpose: Option<String>,                                   // a text part, absent when None
#[endpoint(location = "body", multipart(file_name_for = data))]
pub name: String,                                              // metadata only: the file part's filename
#[endpoint(location = "body", multipart(name = "file", kind = "stream"))]
pub data: FileBlob,                                            // a streamed file part
#[endpoint(location = "body", multipart(mime_type_for = data))]
pub content_type: String,                                      // metadata only: the file part's Content-Type
  • A text part has no kind. name = "…" names it on the wire (the bare field identifier otherwise — a #[wire(rename)] on a multipart field is refused, because that key names the field on the endpoint’s wire binding). The default serializer = "form" renders the value through Display; serializer = "json" and friends encode through config.{json}_lib().encode_to_vec(..), as a typed part when content_type = "…" is given and as a bare stream otherwise.

  • A stream partkind = "stream" — flows through TryInto<Bytestream> into Part::stream, so a large upload never buffers in full. file_name = "…" and mime_type = "…" attach literal metadata.

  • A metadata-only fieldfile_name_for = <field> and/or mime_type_for = <field> — never appears on the wire; its runtime value decorates the named stream part. The “for” direction puts the declaration on the provider, so “this field is not a part” needs no separate flag.

  • A one-of fieldkind = "one_of" — is a field whose type implements MultipartParts and emits its own part(s), owning their wire names:

    impl MultipartParts for SttAudioSource {
        fn push_parts(self, multipart: &mut Multipart) -> Result<(), BodyError> {
            match self {
                Self::Url(url)   => multipart.push_part(Part::text("url", url)),
                Self::File(blob) => multipart.push_part(blob.to_stream_part("file")?),
            }
            Ok(())
        }
    }

An Option part is omitted when None, and a Vec part emits one part per element. The body() for a multipart endpoint is one call to the generated self.multipart_body(config, context), which draws the boundary from the request’s own RNG fork — so concurrent uploads never race for one, and a recorded upload replays byte-for-byte — and sets the content type with it.

Beyond form-data

multipart/form-data is one subtype. capi_base_encoders::Multipart builds any of them by hand, for the endpoints the derive’s field roles do not describe:

let mut body = Multipart::related(context.rng())        // or ::mixed(rng), ::new(rng) for form-data
    .with_root_type("application/json")                  // RFC 2387 envelope parameters
    .with_start("<metadata>");
body.push_part(Part::typed("application/json", metadata_bytes));   // disposition-free, typed
body.push_part(Part::file("file", blob)?);                         // a named form-data part
body.push_part(Part::raw(headers, stream));                        // every header under your control
let prepared = body.prepared()?;                                   // a PreparedBody, boundary and all

Part::text, text_typed, stream, file, typed, raw, and raw_segments are the part constructors; with_file_name / with_mime_type decorate a stream part. A raw part’s content may be several segments emitted back to back, so a section can compose a serialized message head with a live body stream without buffering them together — which is how capi_http_batch writes a captured request into a multipart/mixed envelope. The response side has a parser to match: ResponseBoundary::from_content_type(&content_type)?.split(&body) lifts the boundary from the response’s own Content-Type — never guessed, never carried over from the request — and splits a buffered multipart/* body into its parts, each with its headers and bytes. The Batching chapter is the worked consumer.

Content-coded bodies

A body’s bytes can be transformed on the way out with a StreamCodec: stream.with_codec(Box::new(codec)) wraps a Bytestream in one, and with_codec_sized(codec, ContentLength::Actual(n)) does the same while declaring the coded length, for a codec whose output size is known up front. The capie_* crates ship the codecs — capie_gzip, capie_miniz_oxide (deflate), capie_brotli, and capie_aws_chunked — and S3’s streaming upload is the worked case: with a trailing checksum declared, the blob streams through an AwsChunkedEncoder whose framed length is computed ahead, so the transport emits a real Content-Length and the SigV4 signer, told by the STREAMING-UNSIGNED-PAYLOAD-TRAILER token not to read the body, lets it go out unbuffered.

The Bytestream primitive

Underneath every body (and every response) is Bytestream — Capi’s one body type, portable across sync, async, std, the browser, and no_std. It defines its own read traits rather than using std::io::Read (which needs std), so it works on every target.

Constructing one

let s = Bytestream::from_vec(vec![1, 2, 3]);          // owned bytes; ContentLength::Actual set
let s = Bytestream::from_slice(&bytes);                // copied
let s: Bytestream = vec![1, 2, 3].into();             // via Into
let s = Bytestream::empty();                          // const-constructible
let s = Bytestream::from_reader(reader, Some(ContentLength::Actual(1024)));  // any reader
let s = Bytestream::from_stream(stream, None);        // an async stream of chunks
let s = Bytestream::from_path("upload.bin")?;         // file-backed, re-readable (std)

ContentLength distinguishes Actual(u64) (exact — the transport sets Content-Length) from Estimated(u64) (a hint only — the body streams chunked, the value kept for progress bars). The distinction matters because compression means the wire length and the decoded length often differ, and not every client reports which.

What a body costs to replay

A stream also knows what it is. source_kind() reports a BodySource: Empty, Buffered (owned in memory, or re-readable in place from a seekable source — a replay costs nothing new), or Streaming (produced incrementally by a reader — a replay pins every transmitted byte until the exchange completes, buffering a body of indeterminate size). Request assembly records the same classification on the request head as the body_source spec, and the paths that would have to hold a whole body read it there: capturing a request into a batch envelope refuses a live stream (DrainError::StreamingBody), and a signer that reads the body refuses one rather than materializing it. Whether an upload fits in memory is the developer’s judgement; the framework names the cost and does not guess.

Shared clones for retry

Once bytes are read, they’re gone — a problem for retries. Bytestream solves it with shared clones: Clone (and the explicit create_shared_clone()) yield a handle sharing one internally-buffered source, each with its own read position, so a dozen clones cost no more than one. This is what lets a retry runner, a redirect, and the seal re-send a body without buffering it upfront.

Progress hooks

Bytestream supports pre- and post-read hooks (add_sync_pre_hook / add_sync_post_hook, with add_async_pre_hook / add_async_post_hook on the async lane) keyed by a HookMode (EveryRead, OnFreshData, OnComplete, PerClone(id), …). A post-read hook receives a PostHookContext and is the idiomatic progress bar:

stream.add_sync_post_hook(HookMode::OnFreshData, |ctx| {
    println!("read {} of {} bytes", ctx.position, total);
    Ok(())
});

Hooks return Result<(), BytestreamError>; returning an error cancels the read — the building block for cancellation. This is the same hook surface the framework uses to drive transfer stages, which is how an application reaches read progress and bandwidth caps without attaching hooks itself — the framework can place them where the transmitted body is knowable, and a hand-attached hook cannot.

A gRPC endpoint’s bodies — a Frame for a unary call, a FramedBody over a MessageSource for a client-streaming one — are another ToBody family; the gRPC chapter has them.

With bodies covered, the next chapter is a cookbook of complete endpoint shapes.