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

  1. Qué es una representación
  2. Convenciones de nombres y tipos
  3. Nulos frente a campos ausentes
  4. Fechas, horas y zonas horarias
  5. Importes monetarios
  6. Enumerados y booleanos
  7. Envoltorio o objeto desnudo
  8. Relaciones: incrustar o enlazar
  9. Hipermedia selectiva: el diseño de _links
  10. Expansión y selección de campos
  11. Negociación de contenido
  12. Respuestas que no son JSON

  1. 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.

  1. 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, no strNombre ni nom.
  • Sufijo Id para 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/has en español: el booleano se llama activo, agotado, regalo.
  • Nombres de dominio, no de tabla: origen, no fkOrigen.

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:

  1. El tipo de un campo no cambia nunca. Si stock es 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 y 0 es falso.
  2. Los identificadores son siempre string. Aunque 5001 parezca número, ped_5001 es una cadena opaca, y así queda protegido el día que cambie el formato.

  1. 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 [], nunca null. Así el cliente puede recorrerla sin comprobar.
  • En el PATCH con Merge Patch, null significa "borra" (02-03). Es una asimetría deliberada entre entrada y salida, y hay que documentarla.

  1. 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:00 a +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. Ponerle T00:00:00Z invita a errores de un día por desplazamiento de zona.
  • Nombres coherentes: fecha<Algo> para instantes, <algo>Dias para duraciones. Tienda Aroma evita timestamp a secas.

  1. 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 veces

El 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 totalEuros ya 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 moneda opcional 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.

  1. 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: 1 obliga 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.

  1. 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.

  1. 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:

  1. Se incrusta lo que casi siempre se necesita y es pequeño y estable. El nombre del café y el precioEuros en 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.
  2. Se enlaza lo grande, lo cambiante o lo sensible. El cliente completo, las reseñas de un café, la factura.
  3. Nunca se incrusta una colección sin límite. Un café con 4.000 reseñas no puede llevarlas dentro: van enlazadas y paginadas.
  4. 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" }
  }
}

  1. Hipermedia selectiva: el diseño de _links

En 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.

  1. 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:

curl "https://api.tiendaaroma.example/v1/pedidos/ped_5001?expandir=cliente"
{
  "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.cafe sí; lineas.cafe.resenas no) para evitar consultas incontrolables.
  • El campo original se mantiene: clienteId no desaparece al añadirse cliente. Así el cliente no tiene que escribir dos rutas de acceso distintas.
  • Solo se pueden expandir las relaciones documentadas; un valor desconocido devuelve 400 con parametro_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.

curl "https://api.tiendaaroma.example/v1/cafes?campos=id,nombre,precioEuros&limite=3"
{
  "datos": [
    { "id": "caf_001", "nombre": "Etiopía Yirgacheffe", "precioEuros": 14.50 },
    { "id": "caf_002", "nombre": "Colombia Huila", "precioEuros": 12.90 }
  ],
  "total": 137
}

Reglas:

  • id siempre 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 400 con parametro_invalido, en lugar de ignorarse en silencio: así se detectan las erratas.
  • No se combina con expandir sobre 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).

  1. 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.5

Se 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, br
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": "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:

  • notasCata viene traducido, porque es texto de marketing pensado para el usuario final. tueste no se traduce: es un enumerado, un identificador de máquina (sección 6).
  • Content-Language: ca declara qué se ha servido; es imprescindible, porque el cliente pidió dos idiomas y necesita saber cuál recibió.
  • Vary le 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. Olvidar Vary es 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:

Accept: application/vnd.tiendaaroma.cafe+json
Accept: application/vnd.tiendaaroma.v2+json

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.

  1. 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/factura
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="factura-2026-000188.pdf"
Content-Length: 48213
Accept-Ranges: bytes

Content-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.jpg
HTTP/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 → 413 con cuerpo_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/csv sobre /v1/pedidos.
  • text/event-stream para 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 camelCase con dos campos en snake_case cuela 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/.pdf duplican 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:

  1. El nombre del café dentro de una línea de pedido.
  2. Las 4.000 reseñas de caf_001 en la ficha del café.
  3. El cliente completo dentro de un pedido, en el panel interno.
  4. La dirección de envío dentro de un pedido.
  5. La puntuación media de un café en el listado del catálogo.
  6. 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

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