The Decoder System in Depth
The decoder contract covers what a client author needs
day to day: name a Decoder marker, write one decode_body, and let the
status ladder do the rest. This chapter is the contract underneath — the trait
the pipeline actually drives, the hooks it exposes before and after the wire,
the door a decoder gets for follow-up sends, and why the whole layer is built
from marker types and strategies. It is the chapter to read before building a
decoder the shipped ones don’t cover.
ResponseDecoder
Every endpoint names a type implementing ResponseDecoder<Self> through its
Decoder associated type, and the request pipeline drives decoding through
that type alone:
pub trait ResponseDecoder<Endpt = Self>
where
Endpt: Endpoint,
{
/// Typed state captured from the endpoint before request construction consumes it.
type State: MaybeSend + 'static;
fn state(endpoint: &Endpt) -> Self::State;
/// Request facts this decoder needs recorded before dispatch. Head-only; the default records nothing.
fn prepare_request(
endpoint: &Endpt,
config: &Endpt::ApiConfig,
context: &ApiContext,
head: &mut RequestHead,
) {}
fn decode<Access>(
rsp: Response,
extension: ExtensionOf<Endpt::Requires>,
config: Endpt::ApiConfig,
context: ApiContext,
client: Access,
state: Self::State,
) -> Result<Endpt::Output, ResponseError<Endpt::Error>>
where
Endpt::Requires: Capability,
Access: FollowUpAccess<Endpt::Requires>;
}
On the async lane decode returns impl Future<Output = …> + MaybeSend; the
rest is identical. The pipeline calls the three members in order, and each has
a job the others cannot do:
state(&endpoint)runs before request assembly, while the endpoint is still available.Endpoint::body(self)consumes the endpoint, so anything the decoder will need at decode time has to be snapshotted here:()for a decoder that needs nothing, the endpoint itself for a paged decoder that re-derives the next page’s request from it, an endpoint clone for a switching decoder that hands the response to a sub-endpoint.prepare_request(endpoint, config, context, &mut head)runs during assembly, after the required capability’s ownprepare_requestand before any member, the seal, or a recorder sees the head. It receives the same(config, context)pair the endpoint’s own methods do, and it is head-only by design: bodies and URLs belong to theEndpointmethods that own them. What a decoder records here is what the decode contract implies for the request — the response specs below, or a header the exchange needs.decode(rsp, extension, config, context, client, state)receives the raw response — head and body stream — plus the capability’s typed extension to the plain HTTP exchange (a WebSocket’s upgraded connection;()for a response-shaped capability), the request’s owned config and context, the door, and the state from step 1. It returnsEndpt::Output, so the endpoint declaration is the single source of truth for the output type; a marker’s blanket impl asserts that what it produces is what the endpoint declared, and a mismatch fails at the endpoint declaration site.
Declaring the response before it arrives
A response head has no channel of its own for “here is what I decode into”,
and by the time a response arrives the endpoint that knows has been consumed.
So the declaration rides out with the request, as ResponseSpecs on the
request head’s specs, written by prepare_request:
pub struct ResponseSpecs {
pub ok: Option<BodySpec>, // how a 2xx payload is read
pub error: Option<BodySpec>, // how a 4xx/5xx envelope is read
}
ResponseSpecs::wire::<Shipment, ParcelErrorWire>(config.json_lib()) // the common JSON shape
ResponseSpecs::ok_only::<Shipment>(codec) // failure bodies are not a model of yours
ResponseSpecs::error_only::<ParcelErrorWire>(codec) // the success payload is a stream
The two payloads are separate because they are different models: a 2xx decodes
into the output, a 4xx/5xx into the API’s error envelope — and error bodies
routinely echo request parameters back, secrets included. A 1xx or 3xx has no
declared payload; those bodies are not the endpoint’s response. Fixture tooling
reads the specs to decode a recorded response structurally: masking its
sensitive fields before it reaches disk and comparing it under the model’s
classes at replay. A decoder that declares nothing falls back to comparing the
bytes through the content-type registry. Every body-decode trait carries a
defaulted response_specs(config) hook for exactly this, and BodyDecoder’s
prepare_request writes its answer onto the head.
The door
A decoder that needs another exchange to finish its job — the next page, a
form submission, a resumed stream — does not receive a client. It receives a
door: Access: FollowUpAccess<Endpt::Requires>, captured on the endpoint’s
required lane. Opening a scope on it — client.follow_up(config, &context) —
runs the same traversal every other exchange runs, so a follow-up carries the
pipeline, the seal, the budget, and the transmit invariants without the decoder
naming any of them. The trait carries a hidden marker supertrait; the set of
doors is the framework’s, and an exchange composed anywhere else is not
expressible.
An output that outlives the decode call — a page iterator, a resumable stream
— is handed a door of its own: client.detach() yields a FollowUpChannel,
owned, 'static, and erased, so nothing about the value it was built from has
to outlive the call. The channel is a send channel, not a client: it claims no
budget markers, and a budget value that reaches an adapter through it is
refused loudly rather than dropped. The capability plane
chapter has the door’s scopes.
Why Decoder is a marker
type Decoder = BodyDecoder names a zero-sized marker, not the decode logic.
If decoders were selected by blanket impls — impl ResponseDecoder<E> for E where E: DecodeBody, … where E: DecodeBytestream, and so on — the impls
would overlap for an endpoint implementing two, and the compiler would reject
the set. A marker per lane makes the choice explicit on the endpoint and keeps
one decoder serving every endpoint that shares a response shape.
The marker is generic over a strategy: BodyDecoder<S = ViaEndpoint>. The
default strategy forwards to the endpoint’s own companion impl — DecodeBody
for a buffered body, DecodeDocuments for a document stream through
DocumentStreamDecoder<Framing, S = ViaEndpoint> — so the everyday spelling
is type Decoder = BodyDecoder; plus one impl DecodeBody. A strategy of
your own carries the decode behaviour for a whole family of endpoints: name it
once, and the endpoints need no companion impl at all. That is why the layer is
marker plus strategy rather than blanket impls — Rust’s orphan rule forbids
impl Trait for all T, and a concrete strategy type carries the behaviour
without the overlap:
pub struct PaychexSingleResponse;
impl<E> DecodeBodyStrategy<E> for PaychexSingleResponse
where
E: Endpoint<ApiConfig = PaychexConfig, Error = PaychexError>,
E::Output: for<'de> WireDecode<'de> + WireShape,
{
fn decode_body(body: Vec<u8>, header: ResponseHead, config: &E::ApiConfig, context: ApiContext)
-> Result<E::Output, ResponseError<E::Error>>
{
decode_single::<E::Output>(body, header, config, context)
}
}
pub type PaychexSingle = BodyDecoder<PaychexSingleResponse>; // type Decoder = PaychexSingle;
The newest clients are built this way: one strategy per response convention, an alias per strategy, and endpoints that name the alias.
The escape hatch: type Decoder = Self
When no marker fits, the endpoint implements ResponseDecoder on itself —
the Endpt parameter defaults to Self — and declares type Decoder = Self;.
No marker, no strategy, full control of all three hooks: the endpoint’s own
state snapshots whatever it likes, its prepare_request stamps the head, and
its decode reads the raw response with the door in hand. The shipped decoders
are all built on this contract, so nothing they do is closed to an endpoint
that takes it directly.
The lanes
Each shipped decoder is one implementation of the contract above, and each has its chapter:
| Lane | Marker | Companion trait | Chapter |
|---|---|---|---|
| Buffered body | BodyDecoder<S> | DecodeBody, DecodeBodyFn, DecodeWrappedBodyFn, TryDecodeWrappedBodyFn | the decoder contract |
| Raw bytes | BytestreamDecoder | DecodeBytestream | streaming |
| Resumable download | ResumeDownloadDecoder | ResumableDownload | streaming |
| SSE, chunked, documents | SseDecoder, ChunkedDecoder, DocumentStreamDecoder<Framing, S> | DecodeSse, DecodeChunked, DecodeDocuments | streaming |
| Pages | PagedDecoder | DecodePaged, DecodePagedSimple, PaginatedResponse | pagination |
| Switching | SwitchDecoder | SwitchDecode | switching |
| Resumable and flat-map streams | StreamFnDecoder, FlatMapDecoder | DecodeResumable + Resumable, FlatMapDecode | composing streams |
| Batch envelopes | the capi_http_batch envelope decoders | — | batching |
| WebSocket | WebSocketDecoder | DecodeWebSocket | WebSocket |
| gRPC | GrpcDecoder, GrpcStreamDecoder | — | gRPC |
| HTML forms | FormFlowDecoder | FormFlow | HTML forms |
Every lane that is not the buffered body runs its own framing on the success
path and takes a decode_error hook (or, for chunked bodies, none at all) for
the failure path — the status ladder is a body decoder’s, and the streaming
chapter says what each lane does instead. For the machinery underneath all of
them, see Under the Hood.