Exemplos de paginação de API: offset, página, cursor, keyset, cabeçalho Link e HAL
Seis estilos de paginação sobre os mesmos dados, com uma resposta real e um loop que busca todas as páginas para cada um.
Exemplos executados pela última vez contra a API em
As APIs dividem listas longas em páginas de seis jeitos comuns. Offset e limit pulam um número de itens. Número e tamanho de página contam páginas, como faz o Spring Data. Um cursor é um token opaco, como o que a Stripe usa, e a paginação keyset pede os itens depois do último id. Algumas APIs colocam a próxima URL em um cabeçalho Link, como o GitHub, e o HAL a coloca em links hipermídia. https://api.testingapis.com/api/paginationentre para ver serve os mesmos 100 itens nos seis estilos, para você comparar as respostas lado a lado e testar um cliente contra cada um. É gratuita e não precisa de chave. Cada estilo está abaixo com uma resposta real.
Os seis estilos de relance
| Estilo | Requisição | A próxima página vem de | Usado por |
|---|---|---|---|
| Offset / limit | /offset?offset=20&limit=10 | offset + limit | Muitas APIs sobre SQL |
| Número de página (a partir de 0) | /page?page=2&size=10 | number + 1, até last ser true | Spring Data |
| Cursor | /cursor?limit=10&cursor=… | next_cursor, até has_more ser false | Stripe |
| Keyset | /keyset?after_id=20&limit=10 | next_after_id | Tabelas grandes, feeds |
| Cabeçalho Link (a partir de 1) | /link?page=3&per_page=10 | A URL rel="next" do cabeçalho Link | GitHub |
| HAL | /hal?page=2&size=10 | _links.next.href | Spring HATEOAS |
Todos os caminhos ficam em /api/paginationentre para ver. O tamanho de página vai de 1 a 100, com padrão de 10.
Offset e limit
O estilo mais simples: pula offset itens e devolve os próximos limit. O total vem no corpo e em X-Total-Count.
$ curl -i "https://api.testingapis.com/api/pagination/offset?offset=0&limit=2"
HTTP/1.1 200 OK
x-total-count: 100
{"data":[{"id":1,"name":"Smart Headset",...},{"id":2,"name":"Compact Speaker",...}],
"pagination":{"offset":0,"limit":2,"total":100,"hasMore":true}}Número e tamanho de página
O envelope do Spring Data. As páginas começam em 0, e esse é o bug a testar: um cliente escrito para páginas que começam em 1 pula a primeira página sem avisar.
$ curl "https://api.testingapis.com/api/pagination/page?page=0&size=2"
{"content":[{"id":1,...},{"id":2,...}],
"pageable":{"pageNumber":0,"pageSize":2,"offset":0,"paged":true},
"totalElements":100,"totalPages":50,"number":0,"size":2,"numberOfElements":2,
"first":true,"last":false,"empty":false}Cursor
A resposta traz um next_cursor. Devolva-o sem alterar para receber a próxima página, e pare quando has_more for false. O cursor por acaso é JSON em base64, mas o cliente deve tratá-lo como opaco. Um cursor alterado recebe 400:
$ curl "https://api.testingapis.com/api/pagination/cursor?limit=2"
{"object":"list","url":"/api/pagination/cursor",
"data":[{"id":1,...},{"id":2,...}],
"has_more":true,"next_cursor":"eyJsYXN0SWQiOjJ9"}
$ curl "https://api.testingapis.com/api/pagination/cursor?cursor=garbage"
HTTP/1.1 400 Bad Request
{"status":400,"error":"Bad Request","message":"Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."}/api/pagination/cursor?limit=3Keyset
Como um cursor, mas legível: peça os itens com id maior que o último que você viu. Inserções e exclusões em páginas anteriores não deslocam os resultados, e é por isso que feeds e tabelas muito grandes usam esse estilo.
$ curl "https://api.testingapis.com/api/pagination/keyset?after_id=10&limit=2"
{"data":[{"id":11,"name":"Smart Lamp",...},{"id":12,"name":"Compact Headset",...}],
"has_more":true,"next_after_id":12}Cabeçalho Link
O corpo é um array simples, e a navegação fica no cabeçalho Link (RFC 8288), com URLs absolutas para next, prev, first e last. Uma página além do fim devolve um array vazio, não um 404.
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=2"
HTTP/1.1 200 OK
link: <https://api.testingapis.com/api/pagination/link?page=3&per_page=2>; rel="next",
<https://api.testingapis.com/api/pagination/link?page=50&per_page=2>; rel="last",
<https://api.testingapis.com/api/pagination/link?page=1&per_page=2>; rel="first",
<https://api.testingapis.com/api/pagination/link?page=1&per_page=2>; rel="prev"
x-total-count: 100
[{"id":3,"name":"Deluxe Blender",...},{"id":4,"name":"Eco Bottle",...}]HAL
Os itens ficam em _embedded.items, cada um com o próprio link self, e os links de página ficam em _links. O tipo de conteúdo é application/hal+json, que alguns clientes não leem como JSON a menos que você mande.
$ curl "https://api.testingapis.com/api/pagination/hal?page=0&size=2"
{"_embedded":{"items":[
{"id":1,...,"_links":{"self":{"href":"/api/pagination/items/1"}}},
{"id":2,...,"_links":{"self":{"href":"/api/pagination/items/2"}}}]},
"_links":{"self":{"href":"/api/pagination/hal?page=0&size=2"},
"first":{"href":"/api/pagination/hal?page=0&size=2"},
"next":{"href":"/api/pagination/hal?page=1&size=2"},
"last":{"href":"/api/pagination/hal?page=49&size=2"}},
"page":{"size":2,"totalElements":100,"totalPages":50,"number":0}}Buscar todas as páginas
A maioria dos bugs de paginação aparece quando um cliente percorre a lista inteira: para uma página antes, fica em loop na última ou conta as páginas a partir do número errado. Este script busca os 100 itens duas vezes, uma por cursor e outra por cabeçalho Link. Nas duas vezes termina no id 100, então nada foi pulado nem repetido.
const BASE = "https://api.testingapis.com/api/pagination";
// Cursor: follow next_cursor until has_more is false.
async function allByCursor(limit = 25) {
const items = [];
let cursor;
do {
const url = new URL(`${BASE}/cursor`);
url.searchParams.set("limit", limit);
if (cursor) url.searchParams.set("cursor", cursor);
const page = await (await fetch(url)).json();
items.push(...page.data);
cursor = page.has_more ? page.next_cursor : undefined;
} while (cursor);
return items;
}
// Link header: follow rel="next" until there is none.
async function allByLinkHeader(perPage = 25) {
const items = [];
let url = `${BASE}/link?per_page=${perPage}`;
while (url) {
const res = await fetch(url);
items.push(...(await res.json()));
const next = res.headers.get("link")?.match(/<([^>]+)>;\s*rel="next"/);
url = next?.[1];
}
return items;
}
const byCursor = await allByCursor();
const byLink = await allByLinkHeader();
console.log("cursor:", byCursor.length, "items, last id", byCursor.at(-1).id);
console.log("link: ", byLink.length, "items, last id", byLink.at(-1).id);cursor: 100 items, last id 100
link: 100 items, last id 100Qual estilo escolher
- Offset ou número de página quando os usuários pulam para a página N e os dados mudam devagar. Uma linha inserida ou apagada entre duas requisições desloca todas as páginas seguintes, então um item pode ser pulado ou aparecer duas vezes.
- Cursor ou keyset para feeds, rolagem infinita e exportações, em que os dados mudam enquanto alguém navega pelas páginas. Não dá para pular direto para a página 40.
- Cabeçalho Link ou HAL quando você quer que os clientes sigam as URLs que o servidor entrega em vez de montar as próprias. Assim o servidor pode mudar o esquema de paginação sem quebrá-los.