Ejemplos de paginación de API: offset, página, cursor, keyset, cabecera Link y HAL
Seis estilos de paginación sobre los mismos datos, con una respuesta real y un bucle que recorre todas las páginas para cada uno.
Ejemplos ejecutados por última vez contra la API el
Las APIs dividen las listas largas en páginas de seis formas habituales. Offset y limit saltan un número de elementos. Número y tamaño de página cuentan páginas, como hace Spring Data. Un cursor es un token opaco, como el que usa Stripe, y la paginación keyset pide los elementos posteriores al último id. Algunas APIs ponen la URL siguiente en una cabecera Link, como GitHub, y HAL la pone en enlaces hipermedia. https://api.testingapis.com/api/paginationinicia sesión para verlo sirve los mismos 100 elementos en los seis estilos, para que compares las respuestas una al lado de otra y pruebes un cliente contra cada uno. Es gratuita y no necesita clave. Abajo tienes cada estilo con una respuesta real.
Los seis estilos de un vistazo
| Estilo | Petición | La página siguiente sale de | Lo usa |
|---|---|---|---|
| Offset / limit | /offset?offset=20&limit=10 | offset + limit | Muchas APIs sobre SQL |
| Número de página (desde 0) | /page?page=2&size=10 | number + 1, hasta que last sea true | Spring Data |
| Cursor | /cursor?limit=10&cursor=… | next_cursor, hasta que has_more sea false | Stripe |
| Keyset | /keyset?after_id=20&limit=10 | next_after_id | Tablas grandes, feeds |
| Cabecera Link (desde 1) | /link?page=3&per_page=10 | La URL rel="next" de la cabecera Link | GitHub |
| HAL | /hal?page=2&size=10 | _links.next.href | Spring HATEOAS |
Todas las rutas están bajo /api/paginationinicia sesión para verlo. El tamaño de página va de 1 a 100, con 10 por defecto.
Offset y limit
El estilo más sencillo: salta offset elementos y devuelve los limit siguientes. El total está en el cuerpo y en 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 y tamaño de página
El sobre de Spring Data. Las páginas empiezan en 0, y ese es el bug que hay que probar: un cliente escrito para páginas que empiezan en 1 se salta la primera página sin decir nada.
$ 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
La respuesta trae un next_cursor. Devuélvelo sin tocarlo para obtener la página siguiente, y para cuando has_more sea false. El cursor resulta ser JSON en base64, pero un cliente debe tratarlo como opaco. Un cursor alterado recibe un 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 un cursor, pero legible: pide los elementos con un id mayor que el último que viste. Las inserciones y borrados en páginas anteriores no pueden desplazar los resultados, y por eso lo usan los feeds y las tablas muy grandes.
$ 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}Cabecera Link
El cuerpo es un array simple, y la navegación va en la cabecera Link (RFC 8288), con URLs absolutas para next, prev, first y last. Una página más allá del final devuelve un array vacío, no un 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
Los elementos están bajo _embedded.items, cada uno con su propio enlace self, y los enlaces de página están bajo _links. El tipo de contenido es application/hal+json, que algunos clientes no analizan como JSON si no se lo indicas.
$ 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}}Recorrer todas las páginas
Casi todos los bugs de paginación aparecen cuando un cliente recorre la lista entera: para una página antes, entra en bucle en la última o cuenta las páginas desde el número equivocado. Este script obtiene los 100 elementos dos veces, una por cursor y otra por cabecera Link. Las dos veces termina en el id 100, así que no se saltó ni se repitió nada.
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 100Qué estilo elegir
- Offset o número de página cuando los usuarios saltan a la página N y los datos cambian despacio. Una fila insertada o borrada entre dos peticiones desplaza todas las páginas siguientes, así que un elemento puede saltarse o aparecer dos veces.
- Cursor o keyset para feeds, scroll infinito y exportaciones, donde los datos cambian mientras alguien pasa páginas. No puedes saltar directamente a la página 40.
- Cabecera Link o HAL cuando quieres que los clientes sigan las URLs que les da el servidor en lugar de construir las suyas. Así el servidor puede cambiar el esquema de paginación sin romperlos.