Ir para o conteúdo
jsonbeautifiers
Português

JSON Schema: as partes que decidem se a validação funciona

A maioria das configurações quebradas de JSON Schema não lança erro. Elas retornam válido em cima de dados errados.

Cada afirmação desta página foi medida ou tem fonte. Quando não é nem uma coisa nem outra, a página diz isso.

Uma checagem de sintaxe te diz que um documento faz parsing. Não diz nada sobre se {"user_id": null, "email": 4} é algo que o seu serviço deveria aceitar. É essa lacuna que um schema preenche, e vale ser preciso sobre a diferença: o validador responde “isto é JSON bem formado”, um schema responde “este é o documento que me prometeram”.

O modo de falha que custa tempo de verdade não é um schema que rejeita dado bom. Isso é notado em minutos. É um schema que aceita dado ruim, compila sem reclamar, e reporta valid por um ano.

Escolhendo um draft

Há duas respostas e as duas se defendem.

2020-12 para trabalho novo. É o identificador com o qual o OpenAPI 3.1 se alinha, então se você está escrevendo uma descrição de API já está nesse dialeto. Repare no nome: 2020-12 é quando o draft foi fechado, e ele foi anunciado no início de 2021. Os documentos naquele URI foram republicados pela última vez em junho de 2022, o que foi um remendo no texto da especificação, não uma versão nova.

draft-07 para a ferramentaria mais ampla. Ele tem a cauda mais longa de suporte entre linguagens, editores e geradores de código, e muitos validadores fora do ecossistema JavaScript ainda o tratam como padrão. Se o seu schema precisa ser consumido por ferramentas que você não controla, draft-07 é o piso pragmático.

O que você não deve fazer é deixar o $schema de fora e torcer. Um schema sem dialeto declarado é interpretado conforme o que o validador resolveu adotar como padrão, que é exatamente a ambiguidade que você queria eliminar.

As mudanças de 2020-12 em que você vai mexer de fato

Três, em ordem de quantas vezes mordem.

prefixItems substitui a forma de array do items. No draft-07, items com valor de array significava validação posicional de tupla e additionalItems restringia o resto. No 2020-12, as posições vão em prefixItems e items é sempre um único schema que se aplica a tudo que prefixItems não cobre.

unevaluatedProperties e unevaluatedItems são as versões conscientes de composição de additionalProperties e additionalItems. Mais adiante o porquê, porque é esse par que as pessoas erram.

$dynamicRef e $dynamicAnchor substituem $recursiveRef e $recursiveAnchor do 2019-09. A menos que você esteja escrevendo schemas recursivos extensíveis, um tipo árvore que quem chama pode especializar, você nunca vai tocar neles. Se você herdou um schema 2019-09 que usa $recursiveRef, ele não funciona sob um validador 2020-12.

O ponto de entrada do Ajv que falha em silêncio

A exportação padrão do Ajv implementa só draft-07. Os dialetos 2020-12 e 2019-09 moram em pontos de entrada separados. Ligue do jeito óbvio:

const Ajv = require("ajv");           // draft-07, diga o que disser o seu $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 não é uma palavra-chave que esse validador conheça. Palavras-chave desconhecidas são ignoradas, então o valor é conferido apenas por ser um array de duas entradas. Os tipos de elemento que você declarou nunca são olhados. Você recebe true em cima de dados que o seu schema claramente proíbe.

O Ajv tem, sim, salvaguardas. O modo estrito, ligado por padrão, protesta contra palavras-chave desconhecidas, e um $schema que nomeia um meta-schema não carregado lança erro. As duas salvaguardas são desligadas rotineiramente: strict: false é a primeira coisa que as pessoas acrescentam quando um schema usa um vocabulário que o Ajv não reconhece, e um monte de schemas gerados sai sem $schema nenhum. Remova qualquer uma das duas e o que você tem é a aprovação silenciosa.

A ligação correta:

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 o ponto de entrada é ajv/dist/2019. Vale acrescentar um teste que afirme que um documento sabidamente ruim falha. Uma configuração de validação sem teste negativo é uma configuração que ninguém provou que funciona.

format não valida nada por padrão

No 2019-09 e no 2020-12, format é uma anotação, não uma asserção. A especificação diz isso explicitamente. Um validador conforme que veja {"type": "string", "format": "email"} tem permissão de registrar “este valor foi anotado como e-mail” e então aceitar "não é um e-mail" sem reclamar. A maioria faz exatamente isso, a menos que você ligue a asserção.

Então este schema:

{
  "type": "object",
  "properties": {
    "email":     { "type": "string", "format": "email" },
    "created_at":{ "type": "string", "format": "date-time" }
  },
  "required": ["email"]
}

pega um email ausente e pega um email que é número. Não pega "email": "banana" e não pega "created_at": "ontem" a não ser que se tenha dito ao validador para asserir formatos.

No Ajv isso significa duas coisas, não uma: instalar e registrar o ajv-formats, que fornece as implementações de verdade, e usar o ponto de entrada do seu dialeto para que o vocabulário certo esteja em jogo. O Ajv não traz implementação de formato nenhuma. Sem ajv-formats, um format é ou um erro de formato desconhecido no modo estrito ou uma operação nula com o estrito desligado. Nenhuma das duas é validação.

Se a forma de um valor realmente importa, reforce o format com um pattern, ou com minLength e maxLength. Uma regex é asserida por todo validador em todo draft, sem plugin e sem flag. format é documentação que alguns validadores podem ser convencidos a fazer valer.

Datas são a baixa habitual aqui, já que JSON não tem tipo data e todo timestamp do seu payload é, na verdade, só uma string.

additionalProperties versus unevaluatedProperties

additionalProperties só conhece properties e patternProperties no mesmo objeto de schema. Ele não enxerga nada que um $ref ou um ramo de allOf trouxe. Essa única frase explica quase todo relato de bug do tipo “meu schema rejeita um campo que está claramente definido”:

{
  "allOf": [{ "$ref": "#/$defs/base" }],
  "properties": { "role": { "type": "string" } },
  "additionalProperties": false
}

Toda propriedade definida em base agora é rejeitada, porque neste nível a única propriedade conhecida é role. Troque a última linha por "unevaluatedProperties": false e a palavra-chave roda depois de os aplicadores in-place terem feito o trabalho, vê tudo que base avaliou, e rejeita só o que nada explicou.

Regra de bolso: um objeto autônomo com todas as propriedades declaradas localmente leva additionalProperties: false. Qualquer coisa composta com allOf, $ref, if/then ou oneOf leva unevaluatedProperties: false. unevaluatedItems é a mesma relação para arrays e casa naturalmente com prefixItems.

required quer dizer presente, não preenchido

required é uma lista de chaves que precisam existir. É só isso que ele é.

{ "type": "object", "required": ["user_id"] }

{"user_id": null} satisfaz. {"user_id": ""} também. Se null não é aceitável, diga isso no schema da propriedade, porque "type": "string" já exclui null sozinho e ["string", "null"] o admite:

{
  "properties": { "user_id": { "type": "string", "minLength": 1 } },
  "required": ["user_id"]
}

O erro espelhado é marcar um campo anulável como não obrigatório. Uma chave que às vezes está ausente e uma chave que às vezes é null são dois contratos diferentes para o consumidor, e escolher um de propósito faz parte de desenhar a resposta.

Gerando um schema a partir de amostras

Escrever à mão um schema para um payload de quarenta campos é chato o bastante para as pessoas pularem. Gerar um a partir de uma resposta real é o ponto de partida prático, com um requisito que decide se o resultado presta.

Um array de objetos tem que ser mesclado por todos os elementos, não amostrado do primeiro. Pegue só o elemento zero e duas coisas dão errado: uma chave que aparece a partir do elemento três fica de fora de properties inteiramente, e uma chave que por acaso está no elemento zero mas some depois é marcada como required, então o schema rejeita dados que você sabe serem válidos. O comportamento correto é a união de chaves para properties e a interseção para required, com os tipos unidos por chave.

É isso que o gerador de schema faz. Dê a ele uma página de saída real da API em vez de um exemplo digitado à mão, e depois edite o resultado: aperte as strings que têm formatos conhecidos, adicione enum onde o conjunto é fechado, e decida a questão do additionalProperties objeto por objeto. Saída gerada é rascunho, não contrato.

Números também merecem uma olhada. Um gerador vê 9007199254740993 e escreve "type": "integer", o que é honesto quanto ao tipo e silencioso quanto ao fato de que o JavaScript não consegue segurar esse valor.

Onde o esforço se paga

Contratos de API, em que o schema é aquilo contra o que o produtor testa e com o que o consumidor valida, de modo que uma mudança incompatível falha na CI e não nos logs de um cliente. Validação de configuração, em que um erro de digitação num arquivo de deploy vira um erro com um JSON Pointer para a chave culpada em vez de uma desreferência de null três serviços adiante. E geração de código, em que uma definição emite tanto a documentação de referência quanto os tipos TypeScript, de modo que os três não possam se desencontrar.

Nada disso chega por escrever um schema. Chega por escrever um schema que um validador corretamente ligado realmente faz valer.