Congelar un contrato de API sin congelar los datos: snapshots de forma en vez de snapshots de valores
Cambiaste un campo del JSON. ¿A quién le rompiste la integración? Nadie lo sabe hasta que un cliente avisa.
La API pública de CognitionDesk la consumen el widget de chat, páginas de turnos y vidrieras de terceros. Hasta septiembre, esas rutas no tenían versión y ningún test miraba la forma de sus respuestas: si alguien renombraba un campo, el PR salía verde y el widget embebido en el sitio de un cliente dejaba de leerlo.
Lo resolvimos con tres piezas: un prefijo /v1, un header de versión en cada respuesta y tests que congelan la forma de las respuestas, no sus valores.
1. Un snapshot de forma
El snapshot clásico guarda la respuesta entera. El problema es que la respuesta trae fechas, ids y textos que cambian solos, así que el snapshot se rompe todo el tiempo, y el equipo aprende a regenerarlo con -u sin leer el diff. Un test que se actualiza sin mirar no protege nada.
Lo que le importa a quien integra no son los valores sino las claves y sus tipos. Esta función reduce una respuesta a eso:
export function forma(v) {
if (v === null) return 'null';
if (Array.isArray(v)) return v.length ? [forma(v[0])] : ['(vacío)'];
if (typeof v === 'object') {
const o = {};
for (const k of Object.keys(v).sort()) o[k] = forma(v[k]);
return o;
}
return typeof v;
}
forma({ a: 1, b: [{ c: 'x' }], d: null });
// → { a: 'number', b: [{ c: 'string' }], d: 'null' }
Dos decisiones que no se ven a primera vista:
- Las claves van ordenadas. Reordenar un objeto literal en el código no es un cambio de contrato y no tiene que poner nada en rojo.
- Un array se describe por su primer elemento. Por eso los fixtures de cada test arman listas homogéneas a propósito.
Cada test pide la ruta dos veces, con /v1 y sin prefijo, y exige que las dos respuestas sean iguales antes de mirar la forma:
/** Pide `/v1<ruta>` y `<ruta>`, exige que sean iguales y devuelve el cuerpo. */
async function pedirEnLasDos(ruta) {
const [conV1, sinV] = await Promise.all([fetch(`${base}/v1${ruta}`), fetch(`${base}${ruta}`)]);
expect(conV1.headers.get(VERSION_HEADER)).toBe('1');
expect(sinV.headers.get(VERSION_HEADER)).toBe('1');
expect(conV1.status).toBe(sinV.status);
const [a, b] = await Promise.all([conV1.json(), sinV.json()]);
expect(a).toEqual(b);
return { status: conV1.status, body: a };
}
it('GET /public/booking/:slug', async () => {
const { status, body } = await pedirEnLasDos('/public/booking/consultorio-demo');
expect(status).toBe(200);
expect(forma(body)).toMatchSnapshot();
});
Y el snapshot que se commitea es legible: es la lista de lo que un cliente puede leer.
"config": {
"assistantId": "string",
"botName": "string",
"openByDefault": "boolean",
"panelSize": "string",
"primaryColor": "string",
...
}
Este snapshot sólo cambia cuando alguien agrega un campo, lo saca o le cambia el tipo. Es decir, sólo cuando hay que preguntarse a quién afecta.
2. Qué cubren los 18 tests
- Config pública del widget, página de turnos y vidriera: la forma del JSON de éxito y la forma del 404.
- Stream del chat: ahí no hay un JSON que reducir, así que el contrato es el enmarcado. Cada línea es
data: {type, data}, el último evento esdata: [DONE], y si algo falla a mitad de camino el último evento es{type: "error", data: {message}}, nunca un JSON suelto después de los headers. - Versión: que
/v1/<ruta>responda igual que/<ruta>, que el header salga siempre y que una versión que no existe dé 404.
Antes de darlos por buenos, los probamos en rojo: renombramos phone en la respuesta de la página de turnos y cambiamos el [DONE] del stream. Los dos cambios pusieron el check en rojo. Un test de contrato que nunca viste fallar no sabés si prueba algo.
3. Congelar también lo raro
Escribir los tests sacó a la luz dos cosas que ya estaban así en producción:
- La disponibilidad de la página de turnos agrega
cached: truesólo cuando la respuesta sale de caché. La misma ruta tiene dos formas. El test pide dos veces (un miss y un hit) y congela exactamente eso:cachedes la única diferencia permitida entre las dos. - El 404 de la config del widget es
{ error }y el de la página de turnos es{ success, error }.
No las arreglamos en el mismo cambio, y es a propósito. El objetivo del contrato es describir lo que los clientes ya reciben hoy. "Arreglar" una forma rara es un cambio de contrato como cualquier otro, y va en su propio PR, mirando a quién afecta.
4. El /v1 va en la raíz
$ curl -si https://api.cognitiondesk.com/v1/health | grep -i version
x-cognitiondesk-version: 1
$ curl -s https://api.cognitiondesk.com/v2/health
{"success":false,"error":"unsupported_api_version","message":"API v2 no existe. Versiones: v1."}
/v1/<ruta> es un alias de /<ruta>: un middleware le saca el prefijo a la URL antes de CORS y del router, así que rate limits, auth y CORS ven la misma ruta de siempre. Las rutas sin versión siguen andando como v1, y nadie se rompió el día del deploy. Cada respuesta, versionada o no, lleva X-CognitionDesk-Version, para que un cliente pueda decir contra qué contrato habló.
La primera idea fue poner la versión adentro de cada base: /public/booking/v1/:slug. Esa ruta le quitaba la página a cualquier negocio cuyo slug fuera v1. En la raíz no hay nada montado que se llame v1, y es una sola regla para todas las superficies.
Un cambio incompatible va a ser v2, con v1 viva al menos 90 días.
En CognitionDesk
El widget de chat ya habla /v1 por defecto (@cognitiondesk/widget 1.9.0). Si integrás contra la API pública, usá el prefijo y mirá el header. Si tu integración necesita otra garantía, escribinos por el chat de esta página.
Comentarios y correcciones
Creá tu primer agente IA hoy
RAG sobre tus documentos, tools, workflows y widget web — con plan gratuito.
Empezar gratis