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

EstiloRequisiçãoA próxima página vem deUsado por
Offset / limit/offset?offset=20&limit=10offset + limitMuitas APIs sobre SQL
Número de página (a partir de 0)/page?page=2&size=10number + 1, até last ser trueSpring Data
Cursor/cursor?limit=10&cursor=…next_cursor, até has_more ser falseStripe
Keyset/keyset?after_id=20&limit=10next_after_idTabelas grandes, feeds
Cabeçalho Link (a partir de 1)/link?page=3&per_page=10A URL rel="next" do cabeçalho LinkGitHub
HAL/hal?page=2&size=10_links.next.hrefSpring 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.

Terminal
$ 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}}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

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.

Terminal
$ 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}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

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:

Terminal
$ 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."}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita
GET/api/pagination/cursor?limit=3
Esta requisição usa um endpoint para membros conectados.EntrarCriar uma conta gratuita

Keyset

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.

Terminal
$ 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}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

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.

Terminal
$ 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",...}]
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

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.

Terminal
$ 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}}
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita

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.

paginate.mjs
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);
Este exemplo usa endpoints para membros conectados.EntrarCriar uma conta gratuita
Saída
cursor: 100 items, last id 100
link:   100 items, last id 100

Qual 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.