Pagination
A paginated endpoint is an ordinary body endpoint whose response type knows how to
ask for the next page. You get two consumption modes for free: query one page like
any endpoint, or call .paged() for an auto-fetching stream over all of them.
Two ways to consume
// One page — a normal query. You handle next_page tokens yourself.
let page = api.query(ListShipments::new()).await?;
// All pages — an auto-fetching StreamIterator.
let mut all = api.query(ListShipments::new().paged()).await?;
let everything: Vec<_> = all.try_collect().await?;
.paged() — the IntoPaged trait, blanket-implemented for every endpoint whose
output paginates — turns the endpoint into a Paged<T> wrapper whose Output is a
PagedIterator<P>, a StreamIterator handle, so it consumes with the same next() /
try_next() / try_collect() / take() you already know. It refetches the next page
automatically when the current buffer drains. The bounds say what qualifies:
T: Endpoint + Clone + DecodeBody, T::Output: PaginatedResponse<T>, and a
response-shaped lane (Requires: Capability<Response = Response>).
Implementing PaginatedResponse
To make a response paginate, implement PaginatedResponse<Endpoint> on the output
type — three required methods, plus a defaulted total() for an API that reports
one:
impl PaginatedResponse<ListShipments> for ListShipmentsOutput {
type Item = Shipment;
type Metadata = ();
fn items(&mut self) -> Vec<Self::Item> {
core::mem::take(&mut self.shipments) // hand off this page's items
}
fn next_page(&self, current: &ListShipments) -> Option<ListShipments> {
self.next_cursor.clone().map(|c| { // None ends the stream
let mut next = current.clone();
next.cursor = Some(c);
next
})
}
fn metadata(self) -> Self::Metadata {} // whatever is neither an item nor continuation state
}
metadata(self) hands out everything on the page that is neither an item nor
continuation state — a total count, a rate-limit snapshot — and the iterator exposes
it through metadata(); () when there is none.
The load-bearing detail is the signature: next_page(&self, current: &E) -> Option<E> takes the current endpoint and returns the next one (or None to stop).
It gets the current endpoint because the next request is almost always the same query
with one field changed — a cursor, an offset, a page number.
No cycle guard. A
next_pagethat never returnsNone— a server cursor that doesn’t advance — produces an infinite stream. Make sure your stop condition actually triggers.
Cursor, offset, and Link-header schemes
The next_page body is where the pagination scheme lives, and it’s just Rust:
- Cursor / continuation token (the common case) — copy the response’s
next_cursor/continuation_tokeninto the next request. grok’scustom_voicesand S3’sListObjectsV2both work this way. - Offset / page number — increment an offset or page field until a page comes back short or empty.
- RFC 5988 Link header — parse
rel="next"from theLinkheader and follow the absolute URL (via an absolute-URL endpoint).capi_base_decoders::LinkHeaderis the parser:LinkHeader::of(&headers).next(&base)(or.rel("next", &base)) resolves the relation against the request’s URL and hands back aUrlBuilder. - A continuation in the response headers — Azure Tables carries its cursor in
x-ms-continuation-NextPartitionKey;next_pagesees only the decoded body, so the body decoder lifts the header values onto the page type first, andnext_pagereads them from there.
Custom decoding: DecodePaged
PaginatedResponse covers the common case where the page is a plain decoded body and
the continuation lives in it — Paged<T> already snapshots the endpoint as its
State and hands it to next_page as current. Reach for DecodePaged when the
page needs the response head — decode_paged receives it beside the body, so a
header-driven continuation needs no lift onto the body type — or when the page is not
the plain decoded output: a custom Page type, or a different NextPage. Like every
decoder it has a state() snapshot, captured before body() consumes the endpoint
and fed into decode_paged(...), which builds the next-page endpoint. A
DecodePagedSimple variant covers the straightforward subset without the full
ceremony.
Consuming a Paged<T>
Because a Paged<T> yields a StreamIterator, the whole streaming
vocabulary applies: try_next() for a page-lazy loop, try_collect()
to gather everything, take(n) to cap the fetch, and size_hint() (backed by the
response’s total() when the API reports one). Items stream out as pages arrive — you
never wait for the last page to start processing the first.
Every follow-up page rides the detached FollowUpChannel the decoder carried out of
the first query, so it runs the same traversal: the config’s members, the seal, the
request budget, the recorder, and the endpoint’s rate_limit — which is re-resolved
per send, so a shared limiter paces the pages too. A wire error on a later page
arrives inside QueryError as a boxed client error, since the concrete client type
is behind the channel. And a derived query input can target the paged flow directly:
an IntoEndpoint whose type Endpoint = Paged<ListShipments> and into_endpoint
ends in .paged(), with Remap = remap::Yes, maps the PagedIterator lazily across
every follow-up page (consuming and patching).
Next: Switching Outputs, where one endpoint yields one of several output types.