Filtrado, ordenación y paginación

Los bugs de los endpoints de lista que nadie nota hasta la página 3.

Un endpoint de lista parece sencillo: pides productos y recibes productos. Luego la lista crece más allá de lo que cabe en una respuesta, los usuarios la quieren ordenada por precio, filtrada por categoría y con búsqueda por nombre, y cada una de esas funcionalidades es un trocito de código con sus propios casos límite. Los bugs de las listas son traicioneros porque la respuesta siempre parece plausible. Una página con los diez productos equivocados sigue siendo una página con diez productos.

Esta lección usa /api/Products, que tiene 20 productos en sus datos iniciales, y los endpoints de demostración de /api/paginationdocs. Los mismos parámetros funcionan en todas las listas de esta API, y la referencia de la API los describe.

ParámetroQué hace aquí
limitTamaño de página, de 1 a 100. Sin él, recibes todo.
pageNúmero de página empezando en 1, usado junto con limit.
offsetSalta este número de registros, como alternativa a page.
sort, orderOrdena por cualquier campo, asc (por defecto) o desc.
qBúsqueda sin distinguir mayúsculas en campos de texto como title y description.
cualquier nombre de campoFiltro exacto sin distinguir mayúsculas, como ?category=books.

Paginación

Pide tres productos por página y mira las cabeceras además del cuerpo:

GET/api/Products?limit=3&page=1

Veinte productos en páginas de tres son siete páginas, y la última tiene dos. La última página es el primer sitio donde buscar bugs, porque es el único caso en que la página no está llena:

Terminal
$ curl -i "https://api.testingapis.com/api/Products?limit=3&page=7"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
x-page: 7
x-per-page: 3
x-total-count: 20
x-total-pages: 7

[{"id":19,"title":"Non-Slip Yoga Mat",...},{"id":20,"title":"Resistance Bands Set",...}]

Correcto: siete páginas, y la última tiene los productos 19 y 20. Un bug clásico aquí es calcular el número de páginas con división entera (20 / 3 = 6), lo que oculta los dos últimos productos a quien vaya pasando las páginas.

Valores fuera del rango

A continuación, envía valores que la documentación dice que no son válidos, o que ni menciona. Esto es lo que hace la API con ellos:

Resultados
?limit=3&page=8    200, x-page: 8, body []
?limit=3&page=0    200, x-page: 1, same as page 1
?limit=0           200, x-per-page: 1
?limit=-1          200, x-per-page: 1
?limit=101         200, x-per-page: 100
?limit=abc         200, x-per-page: 20 (limit ignored, all 20 returned)

El servidor nunca se niega. Ajusta un valor malo al válido más cercano, o lo ignora. Es un diseño legítimo, y amable con los clientes, pero un test debería dejarlo registrado, porque la alternativa (un 400 que explique el rango válido) es igual de habitual. Los casos peligrosos son aquellos en que el ajuste esconde un bug del cliente. Un cliente que envía limit=abc por un error de plantilla recibe aquí los 20 registros. En una tabla con dos millones de filas, el mismo comportamiento significa que el servidor intenta devolverlas todas.

Una página más allá del final devuelve una lista vacía con un 200. Es lo que hacen la mayoría de las APIs, y lo que esperan los clientes que recorren páginas hasta que no reciben nada. Un 404 para una página vacía también se podría defender. Un 500 no.

Sin huecos, sin duplicados

El test de paginación más importante es también el más fácil de describir: recorre todas las páginas, reúne los IDs y comprueba que recibiste cada registro exactamente una vez. Compara cuántos reuniste con X-Total-Count.

JavaScript
async function allPages(path, limit) {
  const seen = [];
  for (let page = 1; ; page++) {
    const res = await fetch(`https://api.testingapis.com${path}?limit=${limit}&page=${page}`);
    const items = await res.json();
    if (items.length === 0) break;
    seen.push(...items.map((item) => item.id));
  }
  return seen;
}

const ids = await allPages("/api/Products", 3);
const total = 20; // from X-Total-Count
console.log(ids.length === total, new Set(ids).size === ids.length);

Ejecútalo con varios tamaños de página: 1, 3, 7 y uno mayor que la lista entera. Los errores por uno en los cálculos de desplazamiento suelen aparecer solo con tamaños concretos. Si la API admite tanto page como offset, comprueba que limit=5&page=3 y limit=5&offset=10 devuelven los mismos registros.

Este test solo pasa de forma fiable si nadie cambia los datos mientras se ejecuta. En un servidor compartido como este, alguien podría hacerlo. Eso lleva a la principal debilidad de los números de página, que se trata más abajo.

Ordenación

Ordena por precio, de mayor a menor, y comprueba el orden del resultado entero, no solo el primer elemento:

GET/api/Products?sort=price&order=desc&limit=5

Los cinco primeros vuelven como 249.99, 199.99, 89.99, 89.99, 59.99. Los productos 3 y 8 cuestan lo mismo. Los empates importan para la paginación: si el servidor no desempata de una forma fija, el mismo producto puede aparecer al final de la página 1 y al principio de la página 2, o en ninguna. La mayoría de las bases de datos no prometen nada sobre el orden de filas iguales salvo que la consulta añada una segunda clave de ordenación, normalmente el ID.

Ordenar por categoría muestra cómo lo resuelve esta API. Dentro de cada categoría, los productos siguen en orden de ID, tanto ascendente como descendente:

Resultados (id:categoría)
?sort=category
10:books 11:books 12:books 13:books 6:clothing 7:clothing 8:clothing 9:clothing
1:electronics 2:electronics 3:electronics 4:electronics 5:electronics 14:home ...

?sort=category&order=desc
18:sports 19:sports 20:sports 14:home 15:home 16:home 17:home 1:electronics ...

Eso es estable, que es lo que quieres. Para probarlo, ordena por un campo con muchos duplicados, recupera la lista dos veces (y recórrela por páginas con un límite pequeño) y comprueba que el orden nunca cambia.

Más casos de ordenación que probar en cualquier API:

  • Cadenas con mayúsculas y minúsculas mezcladas. ¿"apple" va antes o después de "Banana"?
  • Números guardados como cadenas, donde "10" va antes que "9".
  • Registros en los que el campo de ordenación es null o falta. ¿Van primero, al final o desaparecen?
  • Fechas en zonas horarias distintas.

Parámetros que se ignoran

Lo que pasa cuando te equivocas con un parámetro da más problemas que cualquiera de las funcionalidades en sí:

Resultados
?sort=colour&limit=5            200, ids 1,2,3,4,5 (unsorted)
?sort=price&order=sideways      200, sorted ascending
?categry=books                  200, all 20 products
?rating.rate=4.3                200, all 20 products

Todos devuelven 200 y una lista que parece razonable. Una ordenación por un campo que no existe se descarta. Un orden sideways se convierte en ascendente. Un filtro mal escrito, categry, se ignora, así que el cliente pidió libros y recibió los 20 productos. El filtrado por un campo anidado con notación de puntos no está soportado, y también se ignora.

En la práctica, el filtro mal escrito es el peor de todos. Imagina una pantalla que muestra "pedidos del cliente 42", construida sobre un parámetro de filtro al que alguien cambió el nombre. Tras el cambio, cada cliente ve todos los pedidos, y la API devuelve 200 todo el tiempo. Cuando pruebes filtros, incluye siempre un caso en que el filtro debería eliminar la mayoría de los registros, y comprueba que lo hizo. Un test que solo comprueba que "cada producto devuelto es de la categoría books" también pasa con una lista vacía, así que comprueba también el número.

En esta API los filtros son exactos y no distinguen mayúsculas: ?category=Electronics devuelve los cinco productos de electrónica, y ?category=electron devuelve una lista vacía. Los campos booleanos funcionan igual:

GET/api/Products?inStock=false

La búsqueda con q busca subcadenas. Aquí empieza a importar la codificación, porque los términos de búsqueda contienen espacios, ampersands, signos de porcentaje y comillas, y todos tienen un significado especial en una URL:

Resultados (ids)
?q=YOGA              19
?q=yoga+mat          19
?q=yoga%20mat        19
?q=100%25            6   (description contains "100% organic cotton")
?q=%27               11,13   (titles and descriptions with an apostrophe)
?q=                  all 20
?q=a&q=b             []
?category=books&category=home    []

Los espacios funcionan escritos como + o como %20. Un % literal hay que enviarlo como %25, y encuentra correctamente la camiseta de "100% organic cotton". Una búsqueda de un apóstrofo funciona y devuelve dos libros, una pequeña señal de que la búsqueda no construye SQL pegando cadenas (la lección de seguridad explica por qué importa). Si envías un espacio sin codificar, curl se niega directamente a enviar la petición.

Las dos últimas filas merecen una nota. Repetir un parámetro es legal en una URL, y las APIs lo tratan de formas distintas: gana el primer valor, gana el último, los dos valores se combinan con OR, o se unen en una sola cadena. Aquí, repetir q o category devuelve una lista vacía, lo que sugiere que los valores se unieron en algo como books,home que no coincide con nada. Si la interfaz de tu producto llega a permitir elegir dos categorías, aquí hay un bug esperando a que lo encuentren.

Combinarlo todo

Filtra, ordena y pagina en una sola petición, y comprueba que se aplican en el orden correcto: primero el filtro, luego la ordenación y después la página.

GET/api/Products?category=electronics&sort=price&order=desc&limit=2&page=2

La página 2 contiene los productos 3 y 4 (89.99 y 59.99), y X-Total-Countes 5, el número de productos de electrónica, no 20. Si el total ignorara el filtro, un cliente mostraría "página 1 de 10" para una lista de tres páginas. Es un error habitual, porque los totales a menudo se cuentan con una consulta aparte que alguien olvidó actualizar.

Por qué fallan los números de página

Los números de página y los offsets describen una posición en la lista: "salta 20, dame 10". Eso está bien para datos que no cambian. Cuando cambian, las posiciones se mueven:

  1. Recuperas la página 1 (elementos 1 a 10) de una lista ordenada de más nuevo a más antiguo.
  2. Alguien crea un elemento nuevo. Va arriba del todo, y todo baja una posición.
  3. Recuperas la página 2 (posiciones 11 a 20). La posición 11 es ahora el elemento que era el 10, así que lo ves dos veces.

Borrar un elemento hace lo contrario: todo sube una posición y un elemento nunca se muestra. En un feed de noticias es una pequeña molestia. En un proceso que exporta todas las facturas recorriendo páginas, significa que falta una factura en la exportación y nadie se da cuenta.

La paginación por cursor lo evita describiendo dónde te paraste en lugar de cuánto llevas. Cada respuesta te da un token para la siguiente petición:

GET/api/pagination/cursor?limit=3
Esta petición usa un endpoint para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
Respuesta
{
  "object": "list",
  "url": "/api/pagination/cursor",
  "data": [
    { "id": 1, "name": "Smart Headset", "category": "electronics", "price": 42.01, "createdAt": "2026-01-01T21:00:00Z" },
    { "id": 2, "name": "Compact Speaker", "category": "outdoors", "price": 79.02, "createdAt": "2026-01-02T10:00:00Z" },
    { "id": 3, "name": "Deluxe Blender", "category": "office", "price": 116.03, "createdAt": "2026-01-02T23:00:00Z" }
  ],
  "has_more": true,
  "next_cursor": "eyJsYXN0SWQiOjN9"
}
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

Devuelves next_cursor como ?cursor= y sigues hasta que has_moresea false. Un cursor no válido recibe un 400 claro: "Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged."

La paginación keyset es la misma idea sin el envoltorio. El cliente envía el último ID que vio, y el servidor devuelve los registros posteriores:

GET/api/pagination/keyset?after_id=10&limit=3
Esta petición usa un endpoint para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita
Respuesta
{
  "data": [
    { "id": 11, "name": "Smart Lamp", "category": "electronics", "price": 12.11, "createdAt": "2026-01-07T07:00:00Z" },
    { "id": 12, "name": "Compact Headset", "category": "outdoors", "price": 49.12, "createdAt": "2026-01-07T20:00:00Z" },
    { "id": 13, "name": "Deluxe Speaker", "category": "office", "price": 86.13, "createdAt": "2026-01-08T09:00:00Z" }
  ],
  "has_more": true,
  "next_after_id": 13
}

El tercer estilo pone los enlaces de página en una cabecera Link, como hace la API de GitHub. El cuerpo sigue siendo un array normal:

Terminal
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=3"
HTTP/1.1 200 OK
link: <http://api.testingapis.com/api/pagination/link?page=3&per_page=3>; rel="next",
      <http://api.testingapis.com/api/pagination/link?page=34&per_page=3>; rel="last",
      <http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="first",
      <http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="prev"
x-total-count: 100
Este ejemplo usa endpoints para miembros con sesión iniciada.Iniciar sesiónCrear una cuenta gratuita

El número de páginas es correcto (100 elementos en páginas de 3 son 34 páginas). Pero mira las URLs. La petición fue a https, y todos los enlaces apuntan a http. Es un bug real, y habitual: la aplicación funciona detrás de un proxy que gestiona HTTPS, ella misma ve HTTP normal y construye los enlaces a partir de lo que ve. Un cliente que siga estos enlaces o bien recibe una redirección o, en una configuración más estricta, envía su siguiente petición sin cifrar. Comprobar el esquema y el host de cada URL que genera una API es un test barato que lo atrapa.

Para cada estilo de paginación, los tests básicos son los mismos: recorre hasta el final, reúne los IDs, busca huecos y duplicados, y comprueba qué pasa en la última página. Para cursores y keysets, añade un registro mientras recorres y confirma que sigues viendo cada registro original exactamente una vez.

Ejercicio

Elige /api/Booksinicia sesión para verlo y apunta los resultados de cada paso.

  1. Averigua cuántos libros hay con X-Total-Count y recorre la lista con limit=4. ¿Recibiste cada ID una vez?
  2. Ordena por genre. Recupéralo dos veces y con limit=3 a través de las páginas. ¿Es estable el orden dentro de cada género?
  3. Filtra por un género, ordena por price de mayor a menor y pide la página 2. ¿Es X-Total-Count el número filtrado?
  4. Escribe mal el nombre del filtro. ¿Qué vuelve?
  5. Recorre /api/pagination/offsetinicia sesión para verlo y /api/pagination/linkinicia sesión para verlo hasta el final. ¿Dónde indican la última página, y coinciden los dos en el total?

Para cada sorpresa, decide si es un bug, un hueco en la documentación o una decisión de diseño que aun así cuestionarías.

Practica y ve más allá