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

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:

  1. 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.
  2. prepare_request(endpoint, config, context, &mut head) runs during assembly, after the required capability’s own prepare_request and 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 the Endpoint methods 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.
  3. 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 returns Endpt::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:

LaneMarkerCompanion traitChapter
Buffered bodyBodyDecoder<S>DecodeBody, DecodeBodyFn, DecodeWrappedBodyFn, TryDecodeWrappedBodyFnthe decoder contract
Raw bytesBytestreamDecoderDecodeBytestreamstreaming
Resumable downloadResumeDownloadDecoderResumableDownloadstreaming
SSE, chunked, documentsSseDecoder, ChunkedDecoder, DocumentStreamDecoder<Framing, S>DecodeSse, DecodeChunked, DecodeDocumentsstreaming
PagesPagedDecoderDecodePaged, DecodePagedSimple, PaginatedResponsepagination
SwitchingSwitchDecoderSwitchDecodeswitching
Resumable and flat-map streamsStreamFnDecoder, FlatMapDecoderDecodeResumable + Resumable, FlatMapDecodecomposing streams
Batch envelopesthe capi_http_batch envelope decodersbatching
WebSocketWebSocketDecoderDecodeWebSocketWebSocket
gRPCGrpcDecoder, GrpcStreamDecodergRPC
HTML formsFormFlowDecoderFormFlowHTML 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.