JSON Schema: las partes que deciden si la validación funciona
La mayoría de configuraciones rotas de JSON Schema no lanzan error. Devuelven válido sobre datos que están mal.
Cada afirmación de esta página está medida o tiene fuente. Cuando no es ninguna de las dos, lo dice.
Una comprobación de sintaxis te dice que un documento parsea. No dice nada sobre si {"user_id": null, "email": 4} es algo que tu servicio debería aceptar. Ese hueco es para lo que sirve un esquema, y merece la pena ser preciso con la diferencia: el validador responde a «¿es esto JSON bien formado?», un esquema responde a «¿es este el documento que me prometieron?».
El modo de fallo que le cuesta tiempo real a la gente no es un esquema que rechaza datos buenos. Eso se detecta en minutos. Es un esquema que acepta datos malos, compila sin quejarse y reporta valid durante un año.
Elegir un draft
Hay dos respuestas y ambas son defendibles.
2020-12 para trabajo nuevo. Es el identificador con el que se alinea OpenAPI 3.1, así que si estás escribiendo una descripción de API ya estás en este dialecto. Fíjate en el nombre: 2020-12 es cuando se cerró el draft, y se anunció a principios de 2021. Los documentos en ese URI se republicaron por última vez en junio de 2022, lo cual fue un parche al texto de la especificación, no una versión nueva.
draft-07 para el mayor soporte de herramientas. Tiene la cola más larga de soporte entre lenguajes, editores y generadores de código, y muchos validadores fuera del ecosistema JavaScript todavía lo tratan como el valor por defecto. Si tu esquema tiene que ser consumido por herramientas que no controlas, draft-07 es el suelo pragmático.
Lo que no deberías hacer es omitir $schema y esperar lo mejor. Un esquema sin dialecto declarado se interpreta según lo que al validador le apeteciera poner por defecto, que es exactamente la ambigüedad que intentabas eliminar.
Los cambios de 2020-12 que sí vas a tocar
Tres, en orden de frecuencia con la que muerden.
prefixItems reemplaza la forma de array de items. En draft-07, items con un valor de array significaba validación posicional de tupla y additionalItems restringía el resto. En 2020-12, las posiciones van en prefixItems y items es siempre un único esquema que se aplica a todo lo que prefixItems no cubra.
unevaluatedProperties y unevaluatedItems son las versiones conscientes de la composición de additionalProperties y additionalItems. Más abajo el porqué, porque este par es el que la gente hace mal.
$dynamicRef y $dynamicAnchor reemplazan a $recursiveRef y $recursiveAnchor de 2019-09. A menos que estés escribiendo esquemas recursivos extensibles, un tipo árbol que quien llama pueda especializar, nunca los tocarás. Si heredaste un esquema 2019-09 que usa $recursiveRef, no funciona bajo un validador 2020-12.
El punto de entrada de Ajv que falla en silencio
La exportación por defecto de Ajv implementa solo draft-07. Los dialectos 2020-12 y 2019-09 viven en puntos de entrada aparte. Conéctalo de la forma obvia:
const Ajv = require("ajv"); // draft-07, diga lo que diga tu $schema
const ajv = new Ajv({ strict: false });
const schema = {
type: "object",
properties: {
point: {
type: "array",
prefixItems: [{ type: "number" }, { type: "number" }],
minItems: 2,
maxItems: 2
}
}
};
ajv.validate(schema, { point: ["north", "west"] }); // true
prefixItems no es una palabra clave que este validador conozca. Las palabras clave desconocidas se ignoran, así que se comprueba que el valor sea un array de dos entradas y nada más. Los tipos de elemento que declaraste no se miran nunca. Obtienes true sobre datos que tu esquema prohíbe claramente.
Ajv sí tiene salvaguardas. El modo estricto, activo por defecto, protesta ante palabras clave desconocidas, y un $schema que nombre un metaesquema que no ha cargado lanzará error. Ambas salvaguardas se desactivan de forma rutinaria: strict: false es lo primero que la gente añade cuando un esquema usa un vocabulario que Ajv no reconoce, y montones de esquemas generados salen sin $schema en absoluto. Quita cualquiera de las dos y lo que obtienes es el aprobado silencioso.
El cableado correcto:
const Ajv2020 = require("ajv/dist/2020");
const addFormats = require("ajv-formats");
const ajv = new Ajv2020({ strict: true, allErrors: true });
addFormats(ajv);
const validate = ajv.compile(schema);
validate({ point: ["north", "west"] }); // false
console.log(validate.errors);
Para 2019-09 el punto de entrada es ajv/dist/2019. Merece la pena añadir un test que afirme que un documento conocido como malo falla. Una configuración de validación sin test negativo es una configuración que nadie ha demostrado que funcione.
format no valida nada por defecto
En 2019-09 y 2020-12, format es una anotación, no una aserción. La especificación lo dice explícitamente. Un validador conforme que vea {"type": "string", "format": "email"} puede registrar «este valor fue anotado como email» y luego aceptar "no es un email" sin quejarse. La mayoría hace exactamente eso salvo que actives la aserción.
Así que este esquema:
{
"type": "object",
"properties": {
"email": { "type": "string", "format": "email" },
"created_at":{ "type": "string", "format": "date-time" }
},
"required": ["email"]
}
caza un email ausente y caza un email que sea un número. No caza "email": "plátano" y no caza "created_at": "ayer" salvo que se le haya dicho al validador que afirme los formatos.
En Ajv eso significa dos cosas, no una: instalar y registrar ajv-formats, que aporta las implementaciones reales, y usar el punto de entrada de tu dialecto para que el vocabulario correcto esté en juego. Ajv no trae implementaciones de formato propias. Sin ajv-formats, un format es o bien un error de formato desconocido en modo estricto o bien una operación nula con el modo estricto apagado. Ninguna de las dos es validación.
Si la forma de un valor importa de verdad, respalda el format con un pattern, o con minLength y maxLength. Una expresión regular la afirma todo validador en todo draft, sin plugin ni bandera. format es documentación que a algunos validadores se les puede persuadir de hacer cumplir.
Las fechas son la baja habitual aquí, ya que JSON no tiene tipo fecha y toda marca de tiempo en tu payload es en realidad solo una cadena.
additionalProperties frente a unevaluatedProperties
additionalProperties solo conoce properties y patternProperties en el mismo objeto esquema. No puede ver nada que haya traído un $ref o una rama allOf. Esa única frase explica casi todo informe de bug del tipo «mi esquema rechaza un campo que está claramente definido»:
{
"allOf": [{ "$ref": "#/$defs/base" }],
"properties": { "role": { "type": "string" } },
"additionalProperties": false
}
Toda propiedad definida en base queda ahora rechazada, porque en este nivel la única propiedad conocida es role. Cambia la última línea por "unevaluatedProperties": false y la palabra clave se ejecuta después de que los aplicadores en su sitio hayan hecho su trabajo, ve todo lo que base evaluó y rechaza solo lo que nada explicó.
Regla práctica: un objeto autónomo con todas sus propiedades declaradas localmente lleva additionalProperties: false. Cualquier cosa compuesta con allOf, $ref, if/then o oneOf lleva unevaluatedProperties: false. unevaluatedItems es la misma relación para arrays y se empareja de forma natural con prefixItems.
required significa presente, no relleno
required es una lista de claves que deben existir. Eso es todo lo que es.
{ "type": "object", "required": ["user_id"] }
{"user_id": null} satisface esto. {"user_id": ""} también. Si null no es aceptable, dilo en el esquema de la propiedad, porque "type": "string" excluye null por sí solo y ["string", "null"] lo admite:
{
"properties": { "user_id": { "type": "string", "minLength": 1 } },
"required": ["user_id"]
}
El error espejo es marcar un campo anulable como no requerido. Una clave que a veces está ausente y una clave que a veces es null son dos contratos distintos para el consumidor, y elegir uno a propósito es parte de diseñar la respuesta.
Generar un esquema a partir de muestras
Escribir a mano un esquema para un payload de cuarenta campos es lo bastante tedioso como para que la gente se lo salte. Generar uno a partir de una respuesta real es el punto de partida práctico, con un requisito que decide si el resultado sirve.
Un array de objetos tiene que fusionarse a lo largo de todos los elementos, no muestrearse del primero. Toma solo el elemento cero y dos cosas salen mal: una clave que aparece a partir del elemento tres falta por completo en properties, y una clave que casualmente está en el elemento cero pero ausente después queda marcada como required, así que el esquema rechaza datos que sabes que son válidos. El comportamiento correcto es la unión de claves para properties y la intersección para required, con los tipos unidos por clave.
Eso es lo que hace el generador de esquemas. Dale una página de salida real de la API en vez de un ejemplo escrito a mano, y luego edita el resultado: aprieta las cadenas que tienen formatos conocidos, añade enum donde el conjunto sea cerrado y decide la cuestión de additionalProperties objeto por objeto. La salida generada es un borrador, no un contrato.
Los números también merecen una mirada. Un generador ve 9007199254740993 y escribe "type": "integer", lo cual es honesto sobre el tipo y silencioso sobre el hecho de que JavaScript no puede sostener ese valor.
Dónde compensa el esfuerzo
Los contratos de API, donde el esquema es aquello contra lo que el productor testea y con lo que el consumidor valida, así que un cambio incompatible falla en CI y no en los logs de un cliente. La validación de configuración, donde una errata en un archivo de despliegue se convierte en un error con un JSON Pointer a la clave ofensora en vez de una desreferencia de null tres servicios más adentro. Y la generación de código, donde una definición emite tanto la documentación de referencia como los tipos de TypeScript, de modo que los tres no puedan separarse.
Nada de eso llega por escribir un esquema. Llega por escribir un esquema que un validador correctamente cableado haga cumplir de verdad.