Todo lo que hemos diseñado hasta ahora funciona igual de bien con 5 cafés que con 5.000… hasta que llega el primer GET /v1/cafes sobre un catálogo real y la respuesta pesa 40 MB. Las colecciones son el punto donde una API bien diseñada se distingue de una que aguanta solo en el entorno de desarrollo. Esta lección diseña las colecciones grandes de Tienda Aroma: cómo se filtran, cómo se ordenan, cómo se parten en páginas —con los tres modelos existentes y sus consecuencias reales— y cómo se busca texto. Es diseño de contrato, no de implementación: aquí se decide qué parámetros existen y qué prometen, y el módulo 3 los implementará tal cual.

Contenido

  1. Por qué una colección sin límites es un problema
  2. Filtrado: convenios de query params
  3. Filtros por rango y por múltiples valores
  4. Por qué no inventar un lenguaje de consulta en la URL
  5. Ordenación
  6. Paginación por offset
  7. Paginación por página
  8. Paginación por cursor
  9. Comparativa y deep paging
  10. Dónde viajan los metadatos de paginación
  11. Búsqueda de texto
  12. Valores por defecto, límites y documentación

  1. Por qué una colección sin límites es un problema

GET /v1/cafes sin restricciones parece inofensivo. Con 137 cafés lo es. Con 50.000 referencias, o con GET /v1/pedidos sobre el histórico completo, deja de serlo por cuatro motivos simultáneos:

Problema Qué ocurre
Base de datos Un SELECT sin LIMIT recorre y materializa la tabla entera
Memoria del servidor Serializar 50.000 objetos a JSON puede consumir cientos de MB; con varias peticiones a la vez, el proceso muere
Red y cliente 40 MB por una pantalla que muestra 20 filas; en móvil, inaceptable
Disponibilidad Cualquiera puede tumbar la API repitiendo esa llamada: es una denegación de servicio gratuita

El último punto es el importante y el que suele pasarse por alto: una colección sin límite máximo es un vector de ataque, y no hace falta mala intención —basta un script de un socio con un bucle mal escrito—. Por eso la primera decisión no es "cómo pagino", sino "la paginación es obligatoria y el servidor la impone aunque el cliente no la pida".

  1. Filtrado: convenios de query params

Como fijamos en 02-02, los criterios de selección van en la query string. El convenio base de Tienda Aroma es el más simple posible: un parámetro por campo, igualdad exacta.

# Cafés de Colombia
curl "https://api.tiendaaroma.example/v1/cafes?origen=Colombia"

# Cafés de Colombia con tueste medio (los filtros se combinan con Y lógico)
curl "https://api.tiendaaroma.example/v1/cafes?origen=Colombia&tueste=medio"

# Pedidos pagados de un cliente
curl "https://api.tiendaaroma.example/v1/pedidos?clienteId=cli_842&estado=pagado"

Reglas del contrato:

  • El nombre del parámetro es el nombre del campo en la representación (origen, tueste, estado, clienteId). Previsibilidad: quien ha visto el JSON ya sabe filtrar.
  • Varios filtros se combinan con Y lógico. Nunca con O; para eso está el multivalor de la sección 3.
  • Un filtro desconocido devuelve 400 con parametro_invalido. Ignorarlo en silencio es peor: el cliente cree que ha filtrado y recibe todo el catálogo.
  • Un valor inválido devuelve 400: ?tueste=tostadísimo no es un enumerado válido.
  • Sin resultados es 200 con {"datos": [], "total": 0}, nunca 404 (02-03).
  • Los valores se codifican en la URL: ?origen=Etiop%C3%ADa.

Filtros publicados en la v1 de Tienda Aroma:

Colección Filtros
/cafes origen, tueste, precioMin, precioMax, disponible, q
/pedidos clienteId, estado, fechaDesde, fechaHasta
/resenas cafeId, clienteId, estado, puntuacionMin
/clientes q (nombre o correo)

La lista de filtros es cerrada y forma parte del contrato. No se filtra por cualquier campo "porque el ORM lo permite": cada filtro publicado hay que documentarlo, validarlo, probarlo, indexarlo y mantenerlo para siempre.

  1. Filtros por rango y por múltiples valores

3.1. Rangos

El convenio de Tienda Aroma son dos parámetros con sufijo, ambos inclusivos:

# Cafés entre 10 y 15 euros, ambos incluidos
curl "https://api.tiendaaroma.example/v1/cafes?precioMin=10&precioMax=15"

# Pedidos de marzo de 2026
curl "https://api.tiendaaroma.example/v1/pedidos?fechaDesde=2026-03-01&fechaHasta=2026-03-31"

# Reseñas de 4 estrellas o más
curl "https://api.tiendaaroma.example/v1/resenas?puntuacionMin=4"

Convención de sufijos: Min/Max para números, Desde/Hasta para fechas. Los dos extremos son opcionales e independientes: ?precioMin=10 significa "de 10 € en adelante".

Alternativas que existen y que Tienda Aroma no usa, para que las reconozcas:

Estilo Ejemplo Comentario
Sufijos (Tienda Aroma) ?precioMin=10&precioMax=15 Legible, fácil de validar y documentar
Operadores en el valor ?precio=gte:10,lte:15 Compacto, pero hay que parsear el valor
Corchetes ?precio[gte]=10&precio[lte]=15 Estilo JSON:API; feo de codificar en URL
Rango con guion ?precio=10-15 Ambiguo con negativos y con decimales

3.2. Múltiples valores

Para "esto o aquello" sobre el mismo campo, lista separada por comas:

# Cafés de tueste claro o medio
curl "https://api.tiendaaroma.example/v1/cafes?tueste=claro,medio"

# Pedidos pagados o enviados
curl "https://api.tiendaaroma.example/v1/pedidos?estado=pagado,enviado"

La alternativa —repetir el parámetro, ?tueste=claro&tueste=medio— es igual de válida y la usan muchas APIs, pero su comportamiento depende del framework y de la biblioteca del cliente (algunos se quedan con el último valor). La coma es explícita y no admite interpretaciones. Limitación asumida: los valores no pueden contener comas; en Tienda Aroma solo se admite multivalor en enumerados e identificadores, donde eso no ocurre.

Resumiendo la semántica completa, que hay que documentar de forma explícita:

?tueste=claro,medio&origen=Colombia
   →  (tueste = claro O tueste = medio)  Y  (origen = Colombia)

  1. Por qué no inventar un lenguaje de consulta en la URL

Antes o después alguien propondrá algo así:

GET /v1/cafes?filtro=(origen eq 'Colombia' and precio gt 10) or tueste eq 'claro'
GET /v1/cafes?where={"$or":[{"precio":{"$gt":10}},{"tueste":"claro"}]}

Es tentador: resuelve cualquier consulta futura sin tocar la API. Y casi siempre es un error:

  • Hay que escribir un parser y un evaluador, con sus errores de sintaxis, sus mensajes y sus casos límite. Es un proyecto, no un parámetro.
  • Riesgo de inyección: pasar la expresión al motor de datos sin traducirla con cuidado es una vía directa a NoSQL injection o SQL injection (04-02).
  • Imposible de acotar: el cliente puede construir consultas con coste arbitrario. Adiós a los índices y a las previsiones de capacidad.
  • Imposible de documentar bien en OpenAPI: el parámetro es una cadena libre, así que no hay validación automática, ni autocompletado, ni mocks útiles (02-08).
  • La caché sufre: infinitas combinaciones de URL, ninguna reutilizable.

Existen estándares serios para esto —OData y GraphQL (01-07)— y la lección es que, si de verdad necesitas consultas arbitrarias, adoptas uno de ellos con los ojos abiertos, no te inventas un dialecto. Tienda Aroma se queda con filtros explícitos: cubren los casos de uso reales, se documentan solos y son predecibles en coste. Si aparece una consulta legítima que no encaja, se añade un filtro nuevo (cambio retrocompatible, 02-07) o se crea un recurso específico.

  1. Ordenación

Un solo parámetro, ordenar, con el nombre del campo y un guion delante para el orden descendente:

# Del más barato al más caro
curl "https://api.tiendaaroma.example/v1/cafes?ordenar=precioEuros"

# Del más caro al más barato
curl "https://api.tiendaaroma.example/v1/cafes?ordenar=-precioEuros"

# Pedidos más recientes primero
curl "https://api.tiendaaroma.example/v1/pedidos?ordenar=-fechaCreacion"

# Orden múltiple: por tueste ascendente y, dentro de cada tueste, por precio descendente
curl "https://api.tiendaaroma.example/v1/cafes?ordenar=tueste,-precioEuros"

Detalles del contrato:

  • Campos ordenables cerrados y documentados por colección. /cafes: nombre, precioEuros, stock, fechaCreacion, puntuacionMedia. Un campo no permitido devuelve 400 con parametro_invalido. La razón es de rendimiento: cada campo ordenable necesita su índice.
  • Orden por defecto, también documentado: /cafes por nombre ascendente, /pedidos por -fechaCreacion, /resenas por -fechaCreacion.
  • Orden múltiple con comas, de mayor a menor prioridad.

El orden estable, o por qué esto importa más de lo que parece

Un orden es estable cuando dos peticiones idénticas devuelven los elementos en el mismo orden. Suena obvio, pero no lo es si ordenas por un campo con valores repetidos: la base de datos puede devolver los empates en cualquier orden entre una consulta y la siguiente.

Consecuencia directa y muy real al paginar:

# 40 cafés cuestan exactamente 12.90 €
curl "https://api.tiendaaroma.example/v1/cafes?ordenar=precioEuros&limite=20&desplazamiento=0"
curl "https://api.tiendaaroma.example/v1/cafes?ordenar=precioEuros&limite=20&desplazamiento=20"

Si el motor resuelve los empates de forma distinta en cada consulta, hay cafés que aparecen en las dos páginas y cafés que no aparecen en ninguna. El usuario ve duplicados y pierde elementos, y el bug es intermitente e imposible de reproducir en desarrollo con 10 filas.

Solución, y es contrato: el servidor siempre añade un criterio de desempate único al final del orden solicitado, típicamente el id.

?ordenar=precioEuros   →   ORDER BY precioEuros ASC, id ASC   (implícito)

No aparece en la URL, pero se documenta: "el orden se desempata siempre por id ascendente". Sin esto, ninguna paginación es fiable.

  1. Paginación por offset

El modelo más extendido: "salta N elementos y dame M".

# Primera página
curl "https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=0"

# Segunda página
curl "https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=20"

# Página 7
curl "https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=120"
{
  "datos": [ { "id": "caf_001", "nombre": "Etiopía Yirgacheffe", "precioEuros": 14.50 } ],
  "total": 137
}

A favor: es trivial de entender, permite saltar a cualquier página (desplazamiento = (pagina - 1) * limite) y da el total, con lo que el cliente puede pintar "137 resultados, página 3 de 7".

En contra, dos problemas serios que veremos en la sección 9: se degrada con desplazamientos grandes y no es estable frente a inserciones.

  1. Paginación por página

Es el mismo modelo con otra aritmética, más cómoda para quien pinta paginadores:

curl "https://api.tiendaaroma.example/v1/cafes?pagina=3&porPagina=20"
{
  "datos": [ ],
  "total": 137,
  "pagina": 3,
  "porPagina": 20,
  "totalPaginas": 7
}

Ventaja: el cliente no calcula nada. Inconveniente: es exactamente igual de frágil que el offset, porque por debajo se traduce a un desplazamiento; solo cambia la forma de pedirlo. Y añade una ambigüedad clásica: ¿la primera página es la 1 o la 0? Cualquiera de las dos vale mientras esté documentada; equivocarse cuesta un bug de 20 elementos que nadie ve.

Decisión de Tienda Aroma: no se ofrecen pagina/porPagina. Un único mecanismo (limite/desplazamiento) es más consistente que dos equivalentes, y el cálculo de la página lo hace el cliente en una línea.

  1. Paginación por cursor

En lugar de "salta 5.000", el servidor entrega un puntero opaco a la posición en la que se quedó.

# Primera página: sin cursor
curl "https://api.tiendaaroma.example/v1/pedidos?limite=20&ordenar=-fechaCreacion"
HTTP/1.1 200 OK
Link: <https://api.tiendaaroma.example/v1/pedidos?limite=20&cursor=eyJmIjoiMjAyNi0wMy0xNCIsImkiOiJwZWRfNTAwMSJ9>; rel="next"
Content-Type: application/json

{
  "datos": [ ],
  "cursorSiguiente": "eyJmIjoiMjAyNi0wMy0xNCIsImkiOiJwZWRfNTAwMSJ9"
}
# Página siguiente: se envía el cursor recibido
curl "https://api.tiendaaroma.example/v1/pedidos?limite=20&cursor=eyJmIjoiMjAyNi0wMy0xNCIsImkiOiJwZWRfNTAwMSJ9"

Cómo funciona por dentro: el cursor codifica (normalmente en Base64) los valores de la última fila entregada según el orden vigente —aquí, fechaCreacion e id—. La consulta siguiente no dice "salta 5.000 filas", dice "dame las filas posteriores a (2026-03-14, ped_5001)", que el índice resuelve al instante sea cual sea la profundidad.

Reglas de Tienda Aroma para los cursores:

  • Son opacos. La documentación prohíbe expresamente descodificarlos o construirlos: su contenido puede cambiar sin previo aviso.
  • Incluyen el orden. Un cursor obtenido con ?ordenar=-fechaCreacion no vale para ?ordenar=precioEuros: al mezclarlos, 400 con parametro_invalido.
  • No hay total en las colecciones paginadas por cursor: calcularlo exige contar toda la tabla, que es justo el coste que queríamos evitar. Se documenta la ausencia.
  • No se puede saltar a la página N: solo hay "siguiente" y "anterior". Es el precio del modelo.

  1. Comparativa y deep paging

Criterio Offset (desplazamiento) Por página (pagina) Cursor
Facilidad para el cliente Alta Muy alta Media
Saltar a la página N No
Total de elementos No (caro)
Coste en base de datos Crece con la profundidad Crece con la profundidad Constante
Estable ante inserciones No No
Escalabilidad Baja en colecciones grandes Baja Alta
Cacheabilidad Buena (URLs estables) Buena Media
Uso típico Catálogos, back-office Webs con paginador clásico Feeds, histórico, exportaciones

9.1. El deep paging

Pedir la página 5.000 con offset obliga al motor a leer y descartar 100.000 filas antes de devolver 20:

-- Lo que hace realmente ?limite=20&desplazamiento=100000
SELECT * FROM cafes ORDER BY nombre ASC, id ASC LIMIT 20 OFFSET 100000;

El coste crece linealmente con el desplazamiento: la página 1 tarda milisegundos y la 5.000 puede tardar segundos y castigar a toda la base de datos. Con cursor, el coste es el mismo en la página 1 que en la 5.000.

Mitigación de Tienda Aroma: desplazamiento máximo de 10.000. Superarlo devuelve 400 con parametro_invalido y un mensaje que remite al mecanismo adecuado: "para recorrer la colección completa, usa la paginación por cursor".

9.2. Elementos duplicados y omitidos

El otro problema del offset, ilustrado con /pedidos ordenado por -fechaCreacion:

sequenceDiagram
    participant C as Panel interno
    participant A as API
    C->>A: GET /pedidos?limite=3&desplazamiento=0
    A-->>C: [ped_5010, ped_5009, ped_5008]
    Note over A: Entra un pedido nuevo: ped_5011<br/>Todo se desplaza una posición
    C->>A: GET /pedidos?limite=3&desplazamiento=3
    A-->>C: [ped_5008, ped_5007, ped_5006]
    Note over C: ped_5008 aparece DOS veces<br/>y ningún pedido se ha perdido... esta vez

Con una inserción, se duplica un elemento; con un borrado, se omite uno, que es peor porque es invisible. En un catálogo que cambia poco, es tolerable; en una exportación contable, es inaceptable.

Decisión de Tienda Aroma:

Colección Modelo Motivo
/cafes Offset (limite + desplazamiento) Catálogo pequeño y estable; el panel necesita saltar a una página y ver el total
/resenas Offset Igual, con volumen moderado
/clientes Offset Igual
/pedidos Cursor, con offset admitido hasta desplazamiento=10000 Crece sin límite y recibe inserciones constantes
/clientes/{id}/pedidos Offset Son pocos por cliente

Coexistir es legítimo siempre que cada colección documente cuál usa y los parámetros no se mezclen: enviar cursor y desplazamiento a la vez devuelve 400.

  1. Dónde viajan los metadatos de paginación

Tres sitios posibles, y no son excluyentes.

a) En el cuerpo, aprovechando el envoltorio decidido en 02-05:

{ "datos": [ ], "total": 137 }

b) En cabeceras propias, estilo GitHub:

X-Total-Count: 137
X-Pagina: 3

c) En la cabecera Link (RFC 8288), el mecanismo estándar de la web:

Link: <https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=40>; rel="next",
      <https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=0>; rel="first",
      <https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=0>; rel="prev",
      <https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=120>; rel="last"
Criterio Cuerpo Cabeceras X- Link (RFC 8288)
Estándar No No (X- desaconsejado por RFC 6648)
Visible con HEAD No
Cómodo en JavaScript Muy Medio (hay que leer cabeceras) Medio (hay que parsear)
Enlaces ya construidos No No
Funciona con respuestas no JSON No

Decisión de Tienda Aroma —coherente con lo fijado en el módulo 1—: total en el cuerpo y navegación en la cabecera Link.

HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=60>; rel="next",
      <https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=20>; rel="prev",
      <https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=0>; rel="first",
      <https://api.tiendaaroma.example/v1/cafes?limite=20&desplazamiento=120>; rel="last"

{
  "datos": [ ],
  "total": 137
}

Por qué esta combinación y no otra:

  • Es exactamente la hipermedia selectiva de nivel 2 que decidimos en 01-05: enlaces donde aportan (navegación), sin convertir el cuerpo en un documento hipermedia.
  • Link es un estándar con rel registrados, no una invención de la casa, y funciona igual para JSON, CSV o PDF.
  • Los enlaces vienen construidos: el cliente no recalcula desplazamientos ni arrastra los filtros a mano. Fíjate en que cada enlace conserva todos los parámetros de la petición original —filtros, orden y campos—, que es justo el error que más se comete al implementarlo.
  • total en el cuerpo porque es un dato del resultado, no de la navegación, y el cliente JavaScript lo tiene a mano sin parsear cabeceras.
  • X-Total-Count no se usa: sería duplicar total con un nombre desaconsejado.

Con cursor, Link lleva solo next (y prev si el modelo lo permite), sin first ni last, y el cuerpo no lleva total. Y en la última página no hay rel="next": su ausencia es la señal de fin de colección, y así se documenta.

  1. Búsqueda de texto

Filtrar es igualdad exacta; buscar es otra cosa: parcial, difusa, con relevancia. El parámetro reservado es q:

curl "https://api.tiendaaroma.example/v1/cafes?q=yirgacheffe"
curl "https://api.tiendaaroma.example/v1/cafes?q=chocolate&tueste=medio&ordenar=precioEuros"

Contrato de q en Tienda Aroma:

  • Busca en nombre, origen y notasCata, y así queda documentado (una búsqueda que no dice dónde busca es una caja negra).
  • Insensible a mayúsculas y a acentos: q=etiopia encuentra "Etiopía".
  • Se combina con los filtros con Y lógico.
  • Mínimo 2 caracteres; con menos, 400 con parametro_invalido.
  • El orden por defecto pasa a ser por relevancia cuando hay q, salvo que se indique ordenar explícitamente. Y ojo: ordenar por relevancia no es estable, así que se desempata por id igual que en la sección 5.

¿Cuándo merece la búsqueda su propio recurso?

Cuando deja de ser "filtrar una colección" y se convierte en una funcionalidad con entidad propia:

Señal Ejemplo Recurso propio
Busca en varios tipos de recurso a la vez Cafés, artículos del blog y ayuda GET /v1/busqueda?q=espresso
Devuelve metadatos de búsqueda Puntuación, facetas, sugerencias
Lo resuelve otro motor Elasticsearch, OpenSearch
GET /v1/busqueda?q=espresso

{
  "datos": [
    { "tipo": "cafe", "id": "caf_002", "titulo": "Colombia Huila", "relevancia": 0.91,
      "_links": { "self": { "href": "/v1/cafes/caf_002" } } },
    { "tipo": "articulo", "id": "art_12", "titulo": "Cómo preparar un buen espresso", "relevancia": 0.74 }
  ],
  "total": 2,
  "facetas": { "tueste": { "medio": 1, "oscuro": 1 } }
}

Tienda Aroma no crea /busqueda en la v1: con ?q= sobre cada colección basta. Queda anotado como candidato para cuando exista el blog.

Consultas complejas por POST

Hay un caso legítimo en el que la consulta no cabe en una URL: informes del panel interno con muchos criterios, listas largas de identificadores o expresiones que superan el límite práctico de longitud de una URL (unos 2.000 caracteres en la mayoría de servidores e intermediarios).

POST /v1/pedidos/consultas HTTP/1.1
Content-Type: application/json

{
  "clienteIds": ["cli_842", "cli_843", "…600 más…"],
  "estados": ["pagado", "enviado"],
  "fechaDesde": "2026-01-01",
  "limite": 100
}

Es un compromiso consciente: se pierde la caché y POST deja de ser "crear" para significar "procesar esta consulta", y por eso el recurso se llama consultas (un sustantivo, 02-02) y devuelve 200, no 201. Tienda Aroma no lo incluye en la v1, pero deja escrito el patrón para cuando el panel lo necesite, en lugar de improvisarlo.

  1. Valores por defecto, límites y documentación

Aquí se cierra el contrato de colecciones, y esta tabla es la que el módulo 3 implementará literalmente:

Parámetro Por defecto Máximo Comportamiento al excederlo
limite 20 100 400 con parametro_invalido
desplazamiento 0 10.000 400 con parametro_invalido
campos Todos 30 campos 400
expandir Ninguno 3 relaciones 400
q 100 caracteres 400
ordenar Según colección 3 criterios 400

Dos decisiones que conviene razonar:

  • limite por encima del máximo devuelve 400, no se recorta en silencio. Recortar es tentador ("sé amable con el cliente"), pero deja al consumidor creyendo que ha recibido 1.000 elementos cuando solo tiene 100: paginará mal y perderá datos sin enterarse. Fallar de forma visible es más amable a medio plazo.
  • El servidor pagina aunque el cliente no lo pida. GET /v1/cafes sin parámetros devuelve 20 elementos, total y la cabecera Link con next. No hay forma de pedir "todo".

Y así queda documentado cada parámetro en la referencia (02-08): nombre, tipo, obligatoriedad, valor por defecto, valores admitidos, comportamiento ante valores inválidos y un ejemplo ejecutable. En OpenAPI, esto se escribe una sola vez como parámetros reutilizables y se referencia desde cada colección:

# Fragmento del contrato: parámetros comunes de colección
components:
  parameters:
    limite:
      name: limite
      in: query
      description: Número máximo de elementos a devolver.
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
      example: 20
    desplazamiento:
      name: desplazamiento
      in: query
      description: Número de elementos a omitir desde el principio de la colección.
      required: false
      schema:
        type: integer
        minimum: 0
        maximum: 10000
        default: 0
      example: 40

Errores Comunes y Consejos

  • No paginar por defecto. El error más caro de esta lección: funciona en desarrollo con 10 filas y tumba producción con 100.000.
  • Ordenar sin desempate único. Duplicados y elementos perdidos al paginar, de forma intermitente e irreproducible.
  • Perder los filtros en los enlaces de paginación. El rel="next" debe arrastrar ordenar, filtros y campos; si no, la página 2 muestra otra cosa que la 1.
  • Ignorar parámetros desconocidos en silencio. Una errata (?tuestte=medio) devuelve el catálogo entero y el cliente cree que ha filtrado.
  • Permitir ordenar o filtrar por cualquier campo. Sin índice, cada consulta es un escaneo completo de la tabla.
  • Recortar limite sin avisar. El cliente pide 1.000, recibe 100 y cree que la colección tiene 100.
  • Dar total en paginación por cursor. Contar la colección entera anula la ventaja del cursor. Es mejor no ofrecerlo y documentarlo.
  • Mezclar modelos de paginación en la misma colección sin reglas: cursor y desplazamiento a la vez deben dar 400.
  • Consejo: prueba siempre con datos que cambian. Inserta filas entre la página 1 y la 2 y comprueba qué pasa. Ese experimento descubre la mitad de los bugs de paginación.
  • Consejo: elige el modelo según el uso, no por moda. El cursor es superior técnicamente, pero si el panel necesita "página 7 de 12", el offset es la respuesta correcta.

Ejercicios

Ejercicio 1: construir consultas

Escribe la petición curl completa para cada necesidad, usando solo el contrato de esta lección:

  1. Cafés de Etiopía o Colombia, con tueste claro, entre 12 y 18 euros, del más caro al más barato, 10 por página, segunda página.
  2. Pedidos del cliente cli_842 pagados o enviados en marzo de 2026, los más recientes primero.
  3. Reseñas pendientes de moderación con 3 estrellas o menos, mostrando solo id, puntuacion y comentario.
  4. Búsqueda de "chocolate" en el catálogo, solo cafés disponibles, ordenados por precio ascendente.

Ejercicio 2: diagnosticar una paginación rota

El panel interno de Tienda Aroma lista pedidos así:

GET /v1/pedidos?ordenar=estado&limite=50&desplazamiento=0
GET /v1/pedidos?ordenar=estado&limite=50&desplazamiento=50
GET /v1/pedidos?ordenar=estado&limite=50&desplazamiento=100

Los usuarios se quejan de dos cosas: (a) a veces ven el mismo pedido en dos páginas, y (b) la última página tarda ocho segundos con 400.000 pedidos.

Diagnostica cada síntoma y propón la solución concreta, indicando qué se pierde con ella.

Ejercicio 3: diseñar la paginación de una colección nueva

Tienda Aroma añade /v1/eventos, el registro de eventos enviados a RápidoEnvíos (evt_9f2c, pedido.pagado, pedido.enviado…). Características: crece a razón de miles de eventos al día, nunca se modifica ni se borra, y RápidoEnvíos lo usa para reconciliar lo que ha recibido, recorriéndolo entero desde el último punto conocido.

Diseña el contrato de esta colección: modelo de paginación, parámetros, filtros, orden por defecto, metadatos y cabeceras. Justifica cada decisión.

Soluciones

Solución 1

# 1
curl "https://api.tiendaaroma.example/v1/cafes?origen=Etiop%C3%ADa,Colombia&tueste=claro&precioMin=12&precioMax=18&ordenar=-precioEuros&limite=10&desplazamiento=10"

# 2
curl "https://api.tiendaaroma.example/v1/pedidos?clienteId=cli_842&estado=pagado,enviado&fechaDesde=2026-03-01&fechaHasta=2026-03-31&ordenar=-fechaCreacion"

# 3
curl "https://api.tiendaaroma.example/v1/resenas?estado=pendiente_moderacion&puntuacionMax=3&campos=id,puntuacion,comentario"

# 4
curl "https://api.tiendaaroma.example/v1/cafes?q=chocolate&disponible=true&ordenar=precioEuros"

Observaciones: en la 1, la segunda página con limite=10 es desplazamiento=10, y el multivalor de origen usa coma con el acento codificado. En la 3 hace falta un filtro puntuacionMax que no está en la tabla de la sección 2: la respuesta correcta incluye darse cuenta y proponer añadirlo como cambio retrocompatible, en lugar de inventarse ?puntuacion=<=3.

Solución 2

(a) Duplicados: falta el desempate. estado solo tiene tres valores, así que hay decenas de miles de empates y el motor los devuelve en orden distinto en cada consulta. Solución: el servidor añade siempre id como último criterio (ORDER BY estado, id), lo documenta y no depende de que el cliente lo pida. Coste: ninguno, salvo asegurar el índice adecuado. Es un fallo del servidor, no del cliente.

(b) Lentitud: deep paging. Con desplazamiento=399950, el motor lee y descarta 399.950 filas. Solución: paginación por cursor para /pedidos, más el tope de desplazamiento=10000. Lo que se pierde: el panel ya no podrá saltar directamente a la página 5.000 ni mostrar "página 3 de 8.000", porque el cursor solo ofrece siguiente y anterior. Mitigación práctica: casi nadie necesita la página 5.000 —lo que necesita es filtrar mejor—, así que la solución completa combina cursor y filtros por fecha y estado para que el recorrido profundo deje de ser necesario.

Solución 3

Modelo: paginación por cursor, sin ninguna duda. Los tres rasgos de la colección lo exigen: crece sin límite (el offset se degradaría), es de solo escritura y lectura secuencial (nadie necesita "la página 300"), y el caso de uso es exactamente "sigue desde donde lo dejaste", que es la definición de un cursor.

Contrato propuesto:

Aspecto Decisión Justificación
Paginación cursor + limite Coste constante en cualquier profundidad
limite Por defecto 50, máximo 200 Los eventos son pequeños; conviene un lote mayor que el estándar para reconciliar rápido
Orden por defecto fechaCreacion ascendente, desempatado por id Se recorre hacia delante en el tiempo: es el orden natural de un registro
Filtros tipo (pedido.pagado, pedido.enviado), pedidoId, fechaDesde, fechaHasta, estadoEntrega Permiten reconciliar por tipo o reintentar los fallidos
total No se ofrece Contar millones de filas anularía la ventaja del cursor; se documenta la ausencia
Metadatos Link con rel="next"; sin first/last Coherente con el resto de la API y con el modelo de cursor
Fin de colección Ausencia de rel="next" Señal documentada; RápidoEnvíos guarda el último cursor y vuelve más tarde
Cuerpo {"datos": [...], "cursorSiguiente": "..."} El cursor también en el cuerpo, por comodidad del cliente
Inmutabilidad Los eventos no se modifican Un cursor antiguo sigue siendo válido indefinidamente: ventaja decisiva frente al offset

Ejemplo de respuesta:

HTTP/1.1 200 OK
Content-Type: application/json
Link: <https://api.tiendaaroma.example/v1/eventos?limite=50&cursor=eyJmIjoiMjAyNi0wMy0xNFQxMDozMjowMFoiLCJpIjoiZXZ0XzlmMmMifQ>; rel="next"

{
  "datos": [
    { "id": "evt_9f2c", "tipo": "pedido.pagado", "pedidoId": "ped_5001",
      "fechaCreacion": "2026-03-14T10:32:00Z", "estadoEntrega": "entregado" }
  ],
  "cursorSiguiente": "eyJmIjoiMjAyNi0wMy0xNFQxMDozMjowMFoiLCJpIjoiZXZ0XzlmMmMifQ"
}

Conclusión

Las colecciones son donde una API se enfrenta a la realidad, y ahora Tienda Aroma tiene un contrato completo para ellas: filtros explícitos por campo, rangos con Min/Max y Desde/Hasta, multivalor con comas, y el rechazo consciente a inventarse un lenguaje de consulta en la URL; ordenación con ordenar y -campo, con la regla crítica del desempate por id que hace que la paginación sea fiable; tres modelos de paginación entendidos a fondo, con offset para las colecciones estables como /cafes y cursor para las que crecen sin freno como /pedidos, más el tope de desplazamiento que evita el deep paging; los metadatos repartidos entre total en el cuerpo y la cabecera Link estándar con sus rel; búsqueda con ?q= y el criterio para saber cuándo merece recurso propio; y una tabla de valores por defecto y máximos que protege la API de sí misma.

Con esto, el contrato de la v1 está prácticamente cerrado: recursos, métodos, códigos, representaciones y colecciones. Y en cuanto un contrato se publica, empieza el problema siguiente: cambiar sin romper a nadie. En la lección siguiente, 02-07 Versionado de APIs, distinguiremos con precisión qué cambios son retrocompatibles y cuáles rompedores, compararemos las cinco estrategias de versionado —ruta, query, cabecera, media type y fecha—, justificaremos por qué Tienda Aroma versiona en /v1, y diseñaremos el ciclo de deprecación completo con las cabeceras Deprecation y Sunset.

Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful

Módulo 1: Introducción a las APIs RESTful

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados