Ya está diseñado el sobre: sabemos a qué URI se va, con qué método y con qué código responde el servidor. Falta lo que va dentro. Esta lección diseña el cuerpo de las respuestas de Tienda Aroma —los nombres de los campos, sus tipos, cómo se representan fechas e importes, qué se incrusta y qué se enlaza— y las cabeceras que gobiernan ese cuerpo. Es una lección de decisiones pequeñas y muy duraderas: el nombre de un campo publicado es contrato para años, y equivocarse con los importes monetarios se paga en céntimos perdidos. Al terminar tendrás la representación canónica de cada recurso y las reglas de negociación de contenido que el módulo 3 implementará.
Contenido
- Qué es una representación
- Convenciones de nombres y tipos
- Nulos frente a campos ausentes
- Fechas, horas y zonas horarias
- Importes monetarios
- Enumerados y booleanos
- Envoltorio o objeto desnudo
- Relaciones: incrustar o enlazar
- Hipermedia selectiva: el diseño de
_links - Expansión y selección de campos
- Negociación de contenido
- Respuestas que no son JSON
- Qué es una representación
Recordemos la distinción de 01-04: el recurso es la entidad conceptual (el café caf_001), y la representación es una de sus formas concretas en un momento dado. El mismo recurso puede representarse como JSON en español, JSON en catalán, PDF o imagen JPEG, y todas comparten URI.
graph LR
R["Recurso<br/><b>/v1/pedidos/ped_5001/factura</b>"] --> A["Accept: application/json<br/>→ JSON con los importes"]
R --> B["Accept: application/pdf<br/>→ PDF con membrete"]
R --> C["Accept-Language: ca<br/>→ mismo contenido en catalán"]
De ahí la regla, ya enunciada en 02-02: el formato no va en la URL, se negocia con cabeceras. Y de ahí también que las decisiones de esta lección sean tan importantes: la representación es lo que el consumidor realmente ve y programa contra ello.
- Convenciones de nombres y tipos
2.1. camelCase
Tienda Aroma usa camelCase en todos los nombres de campo JSON: precioEuros, fechaCreacion, notasCata, clienteId.
| Estilo | Ejemplo | Quién lo usa | Comentario |
|---|---|---|---|
camelCase |
precioEuros |
Google, Stripe (parcial), la mayoría | Natural en JavaScript, que es el consumidor principal |
snake_case |
precio_euros |
Stripe, Twitter/X, Slack | Natural en Python/Ruby, legible |
PascalCase |
PrecioEuros |
APIs .NET antiguas | Poco frecuente hoy |
kebab-case |
precio-euros |
Prácticamente nadie | Incómodo: obliga a obj["precio-euros"] |
Ninguno es mejor en abstracto. Tienda Aroma elige camelCase porque sus consumidores principales son JavaScript (SPA, Aroma Móvil con React Native, servidor Node.js) y así el objeto JSON se usa sin traducción. Lo que sí es obligatorio es no mezclar: {"precioEuros": 14.50, "fecha_creacion": "..."} es el tipo de detalle que envenena una API.
2.2. Reglas de nombrado de campos
- Sin prefijos técnicos ni abreviaturas crípticas:
nombre, nostrNombreninom. - Sufijo
Idpara referencias:clienteId,cafeId. Deja claro que es una referencia y no el objeto. - Sufijo de unidad cuando la haya:
precioEuros,pesoGramos,duracionSegundos. Elimina de un plumazo la pregunta "¿esto en qué unidad está?". - Plural para los arrays:
notasCata,lineas,datos. - Sin
is/hasen español: el booleano se llamaactivo,agotado,regalo. - Nombres de dominio, no de tabla:
origen, nofkOrigen.
2.3. Tipos
| Tipo JSON | Uso en Tienda Aroma | Ejemplo |
|---|---|---|
string |
Texto, identificadores, fechas, enumerados | "caf_001" |
number |
Cantidades e importes | 14.50, 120 |
boolean |
Banderas | true |
array |
Colecciones y listas de valores | ["cítrico", "floral"] |
object |
Estructuras anidadas | {"cafeId": "...", "cantidad": 2} |
null |
Ausencia con significado | "fechaEnvio": null |
Dos reglas duras:
- El tipo de un campo no cambia nunca. Si
stockes número, no puede volverse"120"en otra respuesta. Es un cambio rompedor (02-07) y una fuente inagotable de bugs, porque"0"es verdadero en JavaScript y0es falso. - Los identificadores son siempre
string. Aunque5001parezca número,ped_5001es una cadena opaca, y así queda protegido el día que cambie el formato.
- Nulos frente a campos ausentes
Tres estados posibles para un campo, y hay que elegir qué significa cada uno:
| Forma | Significado que le damos | Ejemplo |
|---|---|---|
| Campo presente con valor | Hay dato | "fechaEnvio": "2026-03-16T09:00:00Z" |
Campo presente con null |
El dato existe conceptualmente pero aún no tiene valor | "fechaEnvio": null (pedido sin enviar) |
| Campo ausente | El campo no aplica a este recurso o no se ha pedido | Sin fechaEnvio en un pedido anulado |
Regla de Tienda Aroma: la representación de un recurso incluye siempre los mismos campos, usando null para lo que todavía no tiene valor. Las únicas ausencias legítimas son (a) los campos que el cliente ha excluido con campos= y (b) los objetos que no se han pedido con expandir=.
Por qué esta regla merece la pena:
// Con la regla: el cliente escribe esto y funciona siempre
const fecha = pedido.fechaEnvio ?? "Pendiente de envío";
// Sin la regla, el cliente tiene que defenderse de tres casos
const fecha = ("fechaEnvio" in pedido)
? (pedido.fechaEnvio === null ? "Pendiente" : pedido.fechaEnvio)
: "Desconocido";Dos matices:
- En arrays no se usa
null: una lista sin elementos es[], nuncanull. Así el cliente puede recorrerla sin comprobar. - En el
PATCHcon Merge Patch,nullsignifica "borra" (02-03). Es una asimetría deliberada entre entrada y salida, y hay que documentarla.
- Fechas, horas y zonas horarias
Todas las fechas y horas van en ISO-8601 con zona horaria explícita, en UTC (Z).
{
"fechaCreacion": "2026-03-14T10:30:00Z",
"fechaPago": "2026-03-14T10:32:15Z",
"fechaEnvio": null,
"fechaCaducidad": "2027-01-31"
}| Formato | Ejemplo | Veredicto |
|---|---|---|
ISO-8601 con Z |
2026-03-14T10:30:00Z |
✅ El estándar de Tienda Aroma |
| ISO-8601 con desplazamiento | 2026-03-14T11:30:00+01:00 |
✅ Se acepta en la entrada, se normaliza a UTC |
| ISO-8601 sin zona | 2026-03-14T10:30:00 |
❌ Ambiguo: ¿la hora de quién? |
| Solo fecha | 2027-01-31 |
✅ Solo cuando la hora no aplica |
| Epoch en segundos | 1773484200 |
❌ Ilegible; ambigüedad segundos/milisegundos |
| Formato local | 14/03/2026 11:30 |
❌ Ambiguo (¿marzo o el día 3?) y dependiente del idioma |
Puntos importantes:
- UTC en el almacenamiento y en el transporte; hora local solo en la presentación. El cliente formatea según la zona del usuario; el servidor jamás supone Madrid.
- Ojo con el horario de verano. España cambia de
+01:00a+02:00; si guardas hora local, dos pedidos de la madrugada del cambio pueden aparecer desordenados o duplicados. - Fechas sin hora (
2027-01-31) para lo que es un día natural, como la caducidad de un lote. PonerleT00:00:00Zinvita a errores de un día por desplazamiento de zona. - Nombres coherentes:
fecha<Algo>para instantes,<algo>Diaspara duraciones. Tienda Aroma evitatimestampa secas.
- Importes monetarios
El apartado donde más dinero se pierde por un descuido técnico. Nunca uses coma flotante binaria para dinero en el servidor.
// El clásico que sorprende a todo el mundo
0.1 + 0.2 // 0.30000000000000004
14.50 * 3 // 43.5 (bien)
0.07 * 100 // 7.000000000000001
(29.00 * 0.21).toFixed(2) // "6.09" ... a vecesEl number de JSON, cuando se procesa como double de IEEE 754, no puede representar exactamente 0.1. Sumar cien líneas de pedido acumula error, y en contabilidad un céntimo de descuadre es un problema real.
Opciones de representación:
| Opción | Ejemplo | A favor | En contra |
|---|---|---|---|
| Número decimal | "precioEuros": 14.50 |
Legible, cómodo para el cliente | Riesgo de coma flotante si el cliente calcula |
| Entero en céntimos | "precioCentimos": 1450 |
Exacto, sin decimales | Todo el mundo debe saber la escala; feo al leerlo |
| Cadena decimal | "precioEuros": "14.50" |
Exacto y sin ambigüedad | Obliga a parsear; incómodo para ordenar |
| Objeto importe | {"importe": "14.50", "moneda": "EUR"} |
Explícito, multidivisa | Verboso |
Decisión de Tienda Aroma:
- En la representación: número con exactamente dos decimales y sufijo
Euros(14.50,29.00). Es legible y directo para los clientes, que solo lo muestran. - En el servidor y la base de datos: enteros de céntimos o tipo decimal exacto. La conversión ocurre en el borde (03-05).
- Los totales los calcula siempre el servidor. El cliente nunca suma importes para mostrarlos como oficiales: por eso el pedido incluye
totalEurosya calculado. - Moneda: la v1 es solo euros y así consta en la documentación. Si algún día hay más divisas, se añade un campo
monedaopcional con valor por defecto"EUR"—cambio retrocompatible (02-07)— en lugar de reestructurar los importes.
Detalle que sorprende: 29.00 en JSON puede serializarse como 29 según la biblioteca, porque JSON no distingue enteros de decimales. Es aceptable —numéricamente son iguales— y el cliente formatea con dos decimales al mostrar. Si te molesta, la alternativa es la cadena decimal, con su coste.
- Enumerados y booleanos
6.1. Enumerados
Los valores enumerados de Tienda Aroma van en snake_case en minúsculas, con valores estables y ampliables:
| Campo | Valores v1 |
|---|---|
tueste |
claro, medio, oscuro |
estado (pedido) |
pendiente_pago, pagado, enviado |
estado (reseña) |
pendiente_moderacion, publicada, rechazada |
estado (envío) |
pendiente_recogida, en_reparto, entregado |
Reglas del contrato:
- El valor no se traduce nunca.
estado: "pagado"es un identificador de máquina; la etiqueta que ve el usuario la pone el cliente. Si mañana hace falta un texto legible, se añade un campo aparte (estadoTexto), no se cambia el valor. - La lista puede crecer. La documentación advierte desde el día uno: trata un valor desconocido con elegancia (principio de robustez, 02-01). Añadir
estado: "devuelto"no debe romper a nadie. - La lista no encoge y los valores no se renombran. Eso sí es rompedor.
- Nada de códigos numéricos.
tueste: 1obliga a mantener una tabla de correspondencias fuera de banda y es ilegible en un log.
6.2. Booleanos
Un booleano solo debe usarse cuando el concepto sea genuinamente binario y para siempre. Muchos "booleanos" acaban convertidos en enumerados: aprobada: true/false no cubre "pendiente de moderación", que es exactamente por qué las reseñas tienen estado y no aprobada. Ante la duda, enumerado: ampliar un enumerado es retrocompatible; convertir un booleano en enumerado, no.
- Envoltorio o objeto desnudo
Para una colección, ¿qué se devuelve?
Opción A, array desnudo:
[
{ "id": "caf_001", "nombre": "Etiopía Yirgacheffe" },
{ "id": "caf_002", "nombre": "Colombia Huila" }
]Opción B, envoltorio (Tienda Aroma):
{
"datos": [
{ "id": "caf_001", "nombre": "Etiopía Yirgacheffe" },
{ "id": "caf_002", "nombre": "Colombia Huila" }
],
"total": 2
}| Criterio | Array desnudo | Envoltorio datos/total |
|---|---|---|
| Simplicidad para el cliente | Máxima: se itera directo | Un nivel más (respuesta.datos) |
| Añadir metadatos después | Cambio rompedor | Aditivo, sin romper nada |
| Total de elementos | No cabe (o va en cabecera) | total |
| Coherencia con las respuestas de elemento | Dos formas distintas | Dos formas distintas igualmente |
| Riesgo histórico de JSON hijacking | Existía en navegadores antiguos | Mitigado |
Decisión de Tienda Aroma: envoltorio para colecciones, objeto desnudo para elementos.
GET /v1/cafes/caf_001 → { "id": "caf_001", "nombre": "...", ... }
GET /v1/cafes → { "datos": [...], "total": 137 }La razón principal es la tolerancia a la evolución: si mañana hay que añadir total, _links o avisos de deprecación a una colección, con el envoltorio es aditivo y con el array desnudo habría que romper a todos los clientes. Y no envolvemos los elementos individuales ({"datos": {...}}) porque añade ruido sin aportar nada: un elemento ya es un objeto extensible.
Consecuencia práctica para el cliente, que hay que documentar bien:
// Colección
const respuesta = await fetch("/v1/cafes").then(r => r.json());
respuesta.datos.forEach(cafe => console.log(cafe.nombre));
console.log(`Hay ${respuesta.total} cafés en total`);
// Elemento
const cafe = await fetch("/v1/cafes/caf_001").then(r => r.json());
console.log(cafe.nombre);Los metadatos de paginación que acompañan a total se deciden en 02-06.
- Relaciones: incrustar o enlazar
Un pedido tiene un cliente y líneas que apuntan a cafés. ¿Cuánto de eso viaja en la respuesta?
Enlazar (linking):
{
"id": "ped_5001",
"clienteId": "cli_842",
"totalEuros": 29.00,
"_links": {
"self": { "href": "/v1/pedidos/ped_5001" },
"cliente": { "href": "/v1/clientes/cli_842" }
}
}Incrustar (embedding):
{
"id": "ped_5001",
"cliente": {
"id": "cli_842",
"nombre": "Marta García",
"email": "[email protected]"
},
"totalEuros": 29.00
}| Criterio | Enlazar | Incrustar |
|---|---|---|
| Tamaño de la respuesta | Mínimo | Mayor |
| Número de llamadas del cliente | Más (chattiness) | Menos |
| Frescura del dato | Siempre actual al pedirlo | Copia del instante de la respuesta |
| Caché | Cada recurso se cachea aparte | Invalida todo el conjunto |
| Riesgo de exponer de más | Bajo | Alto (datos personales, permisos) |
| Acoplamiento | Bajo | Alto |
Criterio de Tienda Aroma:
- Se incrusta lo que casi siempre se necesita y es pequeño y estable. El
nombredel café y elprecioEurosen cada línea de pedido se incrustan, y además el precio se incrusta congelado: el precio del pedido es el que había el día de la compra, no el actual. Aquí incrustar no es una optimización, es corrección de negocio. - Se enlaza lo grande, lo cambiante o lo sensible. El cliente completo, las reseñas de un café, la factura.
- Nunca se incrusta una colección sin límite. Un café con 4.000 reseñas no puede llevarlas dentro: van enlazadas y paginadas.
- Lo demás, bajo demanda con
expandir(sección 10).
Así queda un pedido de Tienda Aroma:
{
"id": "ped_5001",
"clienteId": "cli_842",
"estado": "pendiente_pago",
"totalEuros": 29.00,
"fechaCreacion": "2026-03-14T10:30:00Z",
"fechaPago": null,
"fechaEnvio": null,
"lineas": [
{ "cafeId": "caf_001", "nombre": "Etiopía Yirgacheffe", "cantidad": 2, "precioEuros": 14.50 }
],
"_links": {
"self": { "href": "/v1/pedidos/ped_5001" },
"cliente": { "href": "/v1/clientes/cli_842" },
"pagar": { "href": "/v1/pedidos/ped_5001/pago", "method": "POST" },
"anular": { "href": "/v1/pedidos/ped_5001/anulacion", "method": "POST" }
}
}
- Hipermedia selectiva: el diseño de
_links
_linksEn 01-05 fijamos el nivel objetivo: Richardson 2 sólido con hipermedia selectiva. Vamos a concretar qué significa exactamente en el contrato, porque "selectiva" sin reglas se convierte en caos.
Qué SÍ lleva enlaces:
| Elemento | Enlaces | Motivo |
|---|---|---|
| Todo recurso individual | self |
URI canónica, imprescindible con vistas anidadas (02-02) |
| Todo recurso con relaciones | Enlaces a los recursos relacionados | Evita que el cliente construya URLs |
| Solo los pedidos | Enlaces de acción según el estado | La máquina de estados es real y cambia |
| Colecciones | Paginación mediante cabecera Link |
Se decide en 02-06 |
Qué NO lleva enlaces: los elementos dentro de una colección no llevan _links completos (solo self), para no multiplicar el peso de la respuesta por veinte; los cafés no llevan enlaces de acción, porque no tienen máquina de estados.
Formato del enlace. Objeto con href y, en las acciones, method:
"_links": {
"self": { "href": "/v1/pedidos/ped_5001" },
"pagar": { "href": "/v1/pedidos/ped_5001/pago", "method": "POST" }
}Es la forma de HAL simplificada (01-05), pero sin adoptar application/hal+json ni _embedded: seguimos con application/json, porque no queremos obligar a los clientes a entender un formato hipermedia completo.
Los enlaces de acción según el estado del pedido son el corazón de la hipermedia selectiva:
| Estado | _links presentes |
|---|---|
pendiente_pago |
self, cliente, pagar, anular |
pagado |
self, cliente, factura, envio, anular |
enviado |
self, cliente, factura, envio, devolver |
Y la regla que hace que esto sirva de algo, escrita en la documentación: "si un enlace de acción no está presente, esa acción no es posible ahora; no la construyas a mano". La SPA pinta los botones a partir de _links en vez de replicar la máquina de estados, que es exactamente lo que buscábamos.
Las URIs de los enlaces son relativas al host (/v1/pedidos/ped_5001). Se documenta así para que funcione igual en producción, en preproducción y en local.
- Expansión y selección de campos
Las dos válvulas anunciadas en 02-01 para gobernar la granularidad.
10.1. Expansión (expandir)
Incrusta bajo demanda un recurso relacionado, ahorrando llamadas:
{
"id": "ped_5001",
"clienteId": "cli_842",
"cliente": {
"id": "cli_842",
"nombre": "Marta García",
"email": "[email protected]"
},
"totalEuros": 29.00
}Reglas de Tienda Aroma para expandir:
- Lista separada por comas:
?expandir=cliente,lineas.cafe. - Se admite un solo nivel de profundidad (
lineas.cafesí;lineas.cafe.resenasno) para evitar consultas incontrolables. - El campo original se mantiene:
clienteIdno desaparece al añadirsecliente. Así el cliente no tiene que escribir dos rutas de acceso distintas. - Solo se pueden expandir las relaciones documentadas; un valor desconocido devuelve
400conparametro_invalido. - Máximo tres expansiones por petición.
10.2. Selección de campos (campos)
También llamada sparse fieldsets: el cliente pide solo lo que va a usar.
{
"datos": [
{ "id": "caf_001", "nombre": "Etiopía Yirgacheffe", "precioEuros": 14.50 },
{ "id": "caf_002", "nombre": "Colombia Huila", "precioEuros": 12.90 }
],
"total": 137
}Reglas:
idsiempre se incluye, se pida o no: sin él la respuesta es inutilizable.- Los campos no pedidos se omiten (única excepción legítima a la regla de la sección 3).
- Un campo desconocido devuelve
400conparametro_invalido, en lugar de ignorarse en silencio: así se detectan las erratas. - No se combina con
expandirsobre la misma relación en la v1, para no multiplicar casos.
Esto conecta directamente con el over-fetching que discutimos en 01-07 al comparar REST con GraphQL: campos y expandir cubren el 90 % de la necesidad real sin renunciar a la caché HTTP ni asumir la complejidad de un lenguaje de consultas. Es la respuesta de una API REST bien diseñada a ese argumento.
Y el coste, que hay que asumir con los ojos abiertos: cada combinación de parámetros es una URL distinta y por tanto una entrada de caché distinta. Multiplicar variantes reduce la tasa de aciertos de la caché (04-06).
- Negociación de contenido
Es el mecanismo por el que cliente y servidor acuerdan la representación. El cliente propone con cabeceras Accept-*, el servidor elige y lo declara con Content-*.
| Cabecera del cliente | Qué negocia | Cabecera de respuesta | Error si no hay acuerdo |
|---|---|---|---|
Accept |
Formato | Content-Type |
406 Not Acceptable |
Accept-Language |
Idioma | Content-Language |
406 (o idioma por defecto) |
Accept-Encoding |
Compresión | Content-Encoding |
Se sirve sin comprimir |
Accept-Charset |
Juego de caracteres | (en Content-Type) |
En desuso: hoy todo es UTF-8 |
Content-Type (petición) |
Formato de lo que envía | — | 415 Unsupported Media Type |
11.1. Factores de calidad (q=)
El cliente puede expresar preferencias ponderadas, de 0 a 1 (por defecto, 1):
Accept: application/json;q=1.0, application/xml;q=0.8, */*;q=0.1
Accept-Language: ca;q=1.0, es;q=0.8, en;q=0.5Se lee: "prefiero JSON; si no, XML; en último caso, cualquier cosa" y "prefiero catalán; si no, español; si no, inglés". El servidor recorre las opciones por q descendente y sirve la primera que puede producir.
11.2. Qué negocia Tienda Aroma
GET /v1/cafes/caf_001 HTTP/1.1
Accept: application/json
Accept-Language: ca, es;q=0.8
Accept-Encoding: gzip, brHTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: ca
Content-Encoding: br
Vary: Accept, Accept-Language, Accept-Encoding
{
"id": "caf_001",
"nombre": "Etiopía Yirgacheffe",
"origen": "Etiopía",
"tueste": "claro",
"precioEuros": 14.50,
"notasCata": ["cítric", "floral", "te negre"]
}Fíjate en tres cosas:
notasCataviene traducido, porque es texto de marketing pensado para el usuario final.tuesteno se traduce: es un enumerado, un identificador de máquina (sección 6).Content-Language: cadeclara qué se ha servido; es imprescindible, porque el cliente pidió dos idiomas y necesita saber cuál recibió.Varyle dice a las cachés intermedias que la respuesta depende de esas cabeceras y que no deben servir la versión catalana a quien pida español. OlvidarVaryes un fallo grave y sutil: se detalla en 04-06.
Contrato de idiomas de Tienda Aroma: se sirven es, ca y en; el idioma por defecto es es; si se pide un idioma no disponible, no se devuelve 406, se sirve es y se declara Content-Language: es. Es una decisión pragmática: para el contenido, un idioma alternativo es mejor que un error.
11.3. Compresión
Accept-Encoding: gzip, br permite comprimir. Un catálogo de 137 cafés en JSON puede pasar de 180 KB a unos 15 KB con gzip: es la optimización con mejor relación coste/beneficio de toda la API. Se activa en el servidor o en el gateway y no cambia el contrato. Detalles de rendimiento, en 04-06.
11.4. Media types específicos y versionado por media type
Además de application/json, se pueden definir tipos propios que identifiquen la forma exacta de la representación:
El prefijo vnd. marca los tipos de proveedor. El segundo ejemplo es el versionado por media type, una de las estrategias que compararemos en 02-07. Tienda Aroma no lo usa —versiona en la ruta—, pero conviene reconocerlo: al pedir contenido a una API que versiona así, Accept: application/json te dará la versión que el servidor considere por defecto, que puede no ser la que esperas.
- Respuestas que no son JSON
No todo es JSON, y la negociación de contenido es justo lo que permite convivir sin ensuciar las URIs.
12.1. La factura en PDF
El mismo recurso, dos representaciones:
# Representación JSON: datos estructurados
curl -H "Accept: application/json" \
https://api.tiendaaroma.example/v1/pedidos/ped_5001/factura{
"id": "fac_88",
"pedidoId": "ped_5001",
"numeroFactura": "2026/000188",
"fechaEmision": "2026-03-14T10:33:00Z",
"baseImponibleEuros": 23.97,
"ivaEuros": 5.03,
"totalEuros": 29.00,
"_links": { "self": { "href": "/v1/pedidos/ped_5001/factura" } }
}# Representación PDF: documento para imprimir o archivar
curl -H "Accept: application/pdf" -o factura.pdf \
https://api.tiendaaroma.example/v1/pedidos/ped_5001/facturaHTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="factura-2026-000188.pdf"
Content-Length: 48213
Accept-Ranges: bytesContent-Disposition sugiere el nombre del fichero al descargar, y Accept-Ranges anuncia que se admiten descargas parciales, que es lo que habilita el 206 de 02-04. Y no hay ninguna URL con .pdf: es el mismo recurso.
12.2. Subida de la imagen de un café
Aquí el que envía algo que no es JSON es el cliente. Dos enfoques:
a) Binario directo con PUT sobre el singleton /cafes/{id}/imagen:
curl -i -X PUT "https://api.tiendaaroma.example/v1/cafes/caf_001/imagen" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: image/jpeg" \
--data-binary @yirgacheffe.jpgHTTP/1.1 200 OK
Content-Type: application/json
Location: https://api.tiendaaroma.example/v1/cafes/caf_001/imagen
{
"url": "https://cdn.tiendaaroma.example/cafes/caf_001.jpg",
"anchoPx": 1200, "altoPx": 1200, "bytes": 184320, "formato": "image/jpeg"
}Es limpio, idempotente y no necesita ningún formato adicional.
b) multipart/form-data cuando hay que enviar fichero y metadatos a la vez:
curl -i -X POST "https://api.tiendaaroma.example/v1/cafes/caf_001/imagenes" \
-H "Authorization: Bearer <token>" \
-F "[email protected];type=image/jpeg" \
-F "descripcion=Grano tostado, plano cenital"Decisión de Tienda Aroma: la opción (a), porque en la v1 solo hay una imagen principal por café y la descripción es un campo del propio café. Contrato de la subida:
- Formatos admitidos:
image/jpeg,image/png,image/webp. Cualquier otro →415. - Tamaño máximo: 5 MB. Si se supera →
413concuerpo_demasiado_grande. - La respuesta es JSON, aunque la petición fuera binaria: la respuesta describe el recurso creado, no lo devuelve.
- La imagen se sirve desde la CDN, no desde la API. La API guarda y devuelve su URL.
12.3. Otros formatos que aparecerán
- CSV para exportaciones del panel interno:
Accept: text/csvsobre/v1/pedidos. text/event-streampara el SSE del panel en vivo que decidimos en 01-07: es otro tipo de respuesta negociada, con conexión persistente.
En ambos casos la regla es la misma: el formato se negocia, la URI no cambia.
Errores Comunes y Consejos
- Mezclar convenciones de nombres. Un
camelCasecon dos campos ensnake_casecuela en revisión y luego no se puede quitar sin romper clientes. - Devolver fechas sin zona horaria.
"2026-03-14T10:30:00"es ambiguo; el bug aparece en marzo y en octubre, con el cambio de hora. - Calcular dinero con coma flotante. Guarda céntimos o decimales exactos y convierte en el borde.
- Traducir los enumerados.
estado: "pagat"obliga a los clientes a mantener tablas por idioma. Los valores de máquina no se traducen. - Devolver un array desnudo en las colecciones. Te quedas sin sitio para meter metadatos sin romper el contrato.
- Incrustar objetos grandes "porque es cómodo". La respuesta se dispara, la caché se degrada y acabas exponiendo datos personales donde no tocaba.
- Olvidar
Vary. Con caché intermedia, un usuario puede recibir la respuesta en el idioma de otro. Es el fallo más difícil de reproducir de esta lección. - Poner el formato en la URL.
.json/.pdfduplican identidades; para eso estáAccept. - Consejo: escribe primero el JSON ideal a mano. Antes de mirar el modelo de datos, escribe la respuesta que querrías recibir. Es la mejor defensa contra el volcado de tablas.
- Consejo: revisa cada campo preguntando "¿quién lo consume?". Si no hay respuesta, quítalo: cada campo publicado hay que mantenerlo para siempre.
Ejercicios
Ejercicio 1: corregir una representación
Este es el JSON que propone un equipo para una reseña. Reescríbelo según las convenciones de Tienda Aroma y justifica cada cambio.
{
"Id": 101,
"cafe_id": 1,
"user": { "id": 842, "nombre": "Marta García", "password_hash": "$2b$10$..." },
"puntuacion": "5",
"comentario": "Un café espectacular",
"aprobada": true,
"fecha": "14/03/2026 11:30",
"precio_pagado": 14.5,
"respuestas": null
}Ejercicio 2: diseñar la negociación de contenido
Aroma Móvil, con la app en catalán y en una red lenta, quiere el detalle del pedido ped_5001 con los datos del cliente incluidos, pero solo los campos que pinta en pantalla (id, estado, totalEuros, fechaCreacion).
a) Escribe la petición curl completa con todas las cabeceras pertinentes.
b) Escribe la respuesta del servidor con sus cabeceras.
c) Explica por qué hace falta Vary y qué pasaría exactamente si se omitiera.
Ejercicio 3: incrustar o enlazar
Para cada relación, decide si se incrusta, se enlaza o se ofrece con expandir, y justifícalo con los criterios de la sección 8:
- El nombre del café dentro de una línea de pedido.
- Las 4.000 reseñas de
caf_001en la ficha del café. - El cliente completo dentro de un pedido, en el panel interno.
- La dirección de envío dentro de un pedido.
- La puntuación media de un café en el listado del catálogo.
- El historial de pagos de un cliente en su ficha.
Soluciones
Solución 1
{
"id": "res_101",
"cafeId": "caf_001",
"clienteId": "cli_842",
"puntuacion": 5,
"comentario": "Un café espectacular",
"estado": "publicada",
"fechaCreacion": "2026-03-14T10:30:00Z",
"respuestas": [],
"_links": {
"self": { "href": "/v1/resenas/res_101" },
"cafe": { "href": "/v1/cafes/caf_001" },
"respuestas": { "href": "/v1/resenas/res_101/respuestas" }
}
}| Problema | Corrección |
|---|---|
"Id": 101 |
"id": "res_101": minúscula, cadena y con prefijo de tipo |
cafe_id |
cafeId: camelCase coherente con el resto |
user incrustado |
Se sustituye por clienteId + enlace: el objeto completo no hace falta aquí |
password_hash |
Se elimina. Fuga gravísima: nunca se serializa un campo sensible por incrustar un objeto entero |
"puntuacion": "5" |
Número, no cadena: es una cantidad y se ordena numéricamente |
aprobada: true |
estado: "publicada": el booleano no cubre pendiente_moderacion ni rechazada |
"fecha": "14/03/2026 11:30" |
fechaCreacion en ISO-8601 UTC; el formato local es ambiguo y dependiente del idioma |
precio_pagado |
Se elimina: no pertenece a una reseña; ese dato vive en la línea del pedido |
"respuestas": null |
[]: los arrays vacíos no son null, para que el cliente pueda recorrerlos siempre |
Solución 2
a)
curl -i "https://api.tiendaaroma.example/v1/pedidos/ped_5001?expandir=cliente&campos=id,estado,totalEuros,fechaCreacion" \
-H "Authorization: Bearer <token>" \
-H "Accept: application/json" \
-H "Accept-Language: ca, es;q=0.8" \
-H "Accept-Encoding: gzip, br"b)
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Language: ca
Content-Encoding: br
Vary: Accept, Accept-Language, Accept-Encoding
{
"id": "ped_5001",
"estado": "pendiente_pago",
"totalEuros": 29.00,
"fechaCreacion": "2026-03-14T10:30:00Z",
"cliente": { "id": "cli_842", "nombre": "Marta García", "email": "[email protected]" }
}Nota: estado sigue en español porque es un enumerado de máquina, no texto traducible; la app lo convierte en "Pendent de pagament" al pintarlo. Y cliente aparece aunque no esté en campos porque la expansión es explícita: se documenta así para que no sorprenda.
c) Vary indica a las cachés intermedias (CDN, proxy corporativo, gateway) qué cabeceras de la petición influyen en la respuesta. Sin ella, la caché guardaría esta respuesta bajo la clave "URL" a secas. Consecuencia concreta: el siguiente usuario que pidiera el mismo pedido con Accept-Language: es recibiría la versión en catalán almacenada; y un cliente que no admitiera Brotli podría recibir un cuerpo comprimido con br que no sabe descomprimir, con lo que la respuesta sería basura ilegible.
Solución 3
| # | Decisión | Justificación |
|---|---|---|
| 1 | Incrustar | Pequeño, siempre necesario y, sobre todo, es una copia histórica: el pedido debe mostrar el nombre y el precio del día de la compra |
| 2 | Enlazar | Colección sin límite: nunca se incrusta. _links.resenas apunta a /v1/cafes/caf_001/resenas, paginada (02-06) |
| 3 | expandir=cliente |
El panel lo necesita a menudo, pero incrustarlo siempre expondría datos personales a todos los consumidores y engordaría cada respuesta |
| 4 | Incrustar | Es parte del pedido y también un dato congelado: la dirección a la que se envió, aunque el cliente la cambie después |
| 5 | Incrustar un campo calculado (puntuacionMedia, numeroResenas) |
Son dos números, se muestran en cada tarjeta del catálogo y evitan una llamada por café: enlazar aquí sería chattiness pura |
| 6 | Enlazar | Colección que crece sin límite, con datos sensibles y de uso ocasional: /v1/clientes/cli_842/pagos |
Conclusión
La representación es lo que el consumidor realmente ve, y ahora está diseñada de arriba abajo: camelCase, identificadores como cadenas opacas, campos siempre presentes con null para lo que aún no tiene valor y [] para las listas vacías, fechas ISO-8601 en UTC, importes en euros con dos decimales y céntimos exactos por dentro, enumerados en snake_case ampliables y sin traducir, y el envoltorio {"datos": [...], "total": n} para las colecciones frente al objeto desnudo para los elementos. Hemos fijado qué se incrusta —lo pequeño, estable y congelado, como el nombre y el precio de una línea— y qué se enlaza, cómo son exactamente los _links de la hipermedia selectiva de nivel 2, y cómo expandir y campos dan a cada consumidor la granularidad que necesita sin renunciar a REST. Y hemos cerrado la negociación de contenido con Accept, Accept-Language y Accept-Encoding, con Vary como pieza imprescindible, incluidas las representaciones que no son JSON: la factura en PDF y la subida de imágenes.
Queda una pieza que hemos ido aplazando y que se nota en cuanto el catálogo crece: qué pasa cuando una colección tiene 4.000 elementos. En la lección siguiente, 02-06 Filtrado, ordenación, paginación y búsqueda, diseñaremos las colecciones grandes de Tienda Aroma: los convenios de filtros y rangos, la ordenación estable, los tres modelos de paginación con su tabla comparativa y el problema del deep paging, dónde viajan los metadatos de paginación —total y cabecera Link—, la búsqueda de texto y los límites por defecto que protegen la API.
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
