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_bodyvsencode_to_vec. Different methods, different jobs.encode_body(&value)is the request-body encoder: it returns anEncodedBodyready to be a body, spec included.encode_to_vec(&value)returns a bareVec<u8>and is for the streaming and WebSocket paths that hand raw bytes elsewhere. In an endpointbody(), you wantencode_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
FileBlobfield with nolocationattribute, returned straight frombody():#[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
FileBlobas alocation = "body"scalar field encodes as a base64 / data-URL string (it’s aWireScalar), 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 defaultserializer = "form"renders the value throughDisplay;serializer = "json"and friends encode throughconfig.{json}_lib().encode_to_vec(..), as a typed part whencontent_type = "…"is given and as a bare stream otherwise. -
A stream part —
kind = "stream"— flows throughTryInto<Bytestream>intoPart::stream, so a large upload never buffers in full.file_name = "…"andmime_type = "…"attach literal metadata. -
A metadata-only field —
file_name_for = <field>and/ormime_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 field —
kind = "one_of"— is a field whose type implementsMultipartPartsand 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.