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
- Por qué una colección sin límites es un problema
- Filtrado: convenios de query params
- Filtros por rango y por múltiples valores
- Por qué no inventar un lenguaje de consulta en la URL
- Ordenación
- Paginación por offset
- Paginación por página
- Paginación por cursor
- Comparativa y deep paging
- Dónde viajan los metadatos de paginación
- Búsqueda de texto
- Valores por defecto, límites y documentación
- 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".
- 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
400conparametro_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ísimono es un enumerado válido. - Sin resultados es
200con{"datos": [], "total": 0}, nunca404(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.
- 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:
- 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.
- 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 devuelve400conparametro_invalido. La razón es de rendimiento: cada campo ordenable necesita su índice. - Orden por defecto, también documentado:
/cafespornombreascendente,/pedidospor-fechaCreacion,/resenaspor-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.
No aparece en la URL, pero se documenta: "el orden se desempata siempre por id ascendente". Sin esto, ninguna paginación es fiable.
- 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.
- Paginación por página
Es el mismo modelo con otra aritmética, más cómoda para quien pinta paginadores:
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.
- 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=-fechaCreacionno vale para?ordenar=precioEuros: al mezclarlos,400conparametro_invalido. - No hay
totalen 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.
- 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 | Sí | Sí | No |
| Total de elementos | Sí | Sí | No (caro) |
| Coste en base de datos | Crece con la profundidad | Crece con la profundidad | Constante |
| Estable ante inserciones | No | No | Sí |
| 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.
- 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:
b) En cabeceras propias, estilo GitHub:
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) |
Sí |
Visible con HEAD |
No | Sí | Sí |
| Cómodo en JavaScript | Muy | Medio (hay que leer cabeceras) | Medio (hay que parsear) |
| Enlaces ya construidos | No | No | Sí |
| Funciona con respuestas no JSON | No | Sí | Sí |
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.
Linkes un estándar conrelregistrados, 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. totalen 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-Countno se usa: sería duplicartotalcon 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.
- 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,origenynotasCata, 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=etiopiaencuentra "Etiopía". - Se combina con los filtros con Y lógico.
- Mínimo 2 caracteres; con menos,
400conparametro_invalido. - El orden por defecto pasa a ser por relevancia cuando hay
q, salvo que se indiqueordenarexplícitamente. Y ojo: ordenar por relevancia no es estable, así que se desempata poridigual 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 | Sí |
| Lo resuelve otro motor | Elasticsearch, OpenSearch | Sí |
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.
- 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:
limitepor encima del máximo devuelve400, 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/cafessin parámetros devuelve 20 elementos,totaly la cabeceraLinkconnext. 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: 40Errores 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 arrastrarordenar, filtros ycampos; 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
limitesin avisar. El cliente pide 1.000, recibe 100 y cree que la colección tiene 100. - Dar
totalen 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:
cursorydesplazamientoa la vez deben dar400. - 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:
- 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.
- Pedidos del cliente
cli_842pagados o enviados en marzo de 2026, los más recientes primero. - Reseñas pendientes de moderación con 3 estrellas o menos, mostrando solo
id,puntuacionycomentario. - 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=100Los 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
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
