Vai al contenuto
jsonbeautifiers
Italiano

JSON Schema: le parti che decidono se la validazione funziona

Quasi nessuna configurazione JSON Schema rotta solleva errori. Restituisce valido su dati sbagliati.

Ogni affermazione di questa pagina è misurata oppure ha una fonte. Quando non è né l’una né l’altra, la pagina lo dice.

Un controllo di sintassi ti dice che un documento si analizza. Non dice nulla sul fatto che {"user_id": null, "email": 4} sia qualcosa che il tuo servizio dovrebbe accettare. È a colmare quel divario che serve uno schema, e sulla differenza conviene essere precisi: il validatore risponde a «è JSON ben formato», uno schema risponde a «è il documento che mi era stato promesso».

Il modo di fallire che costa tempo vero non è uno schema che rifiuta dati buoni. Quello si nota in pochi minuti. È uno schema che accetta dati cattivi, compila senza fiatare e riporta valid per un anno.

Scegliere un draft

Ci sono due risposte ed entrambe si difendono.

2020-12 per il lavoro nuovo. È l’identificatore a cui OpenAPI 3.1 si allinea, quindi se stai scrivendo una descrizione di API sei già in questo dialetto. Attenzione al nome: 2020-12 è quando il draft è stato chiuso, ed è stato annunciato all’inizio del 2021. I documenti a quell’URI sono stati ripubblicati l’ultima volta a giugno 2022, e fu una correzione al testo della specifica, non una nuova edizione.

draft-07 per la copertura di strumenti più ampia. Ha la coda di supporto più lunga fra linguaggi, editor e generatori di codice, e molti validatori fuori dall’ecosistema JavaScript lo trattano ancora come impostazione predefinita. Se il tuo schema deve essere consumato da strumenti che non controlli, draft-07 è il pavimento pragmatico.

Quello che non devi fare è omettere $schema e sperare. Uno schema senza dialetto dichiarato viene interpretato secondo ciò che al validatore è parso di adottare come predefinito, cioè esattamente l’ambiguità che volevi togliere di mezzo.

Le modifiche del 2020-12 che toccherai davvero

Tre, in ordine di quanto spesso mordono.

prefixItems sostituisce la forma ad array di items. In draft-07, items con un valore array significava validazione posizionale di tupla e additionalItems vincolava il resto. In 2020-12 le posizioni vanno in prefixItems e items è sempre un unico schema che si applica a tutto ciò che prefixItems non copre.

unevaluatedProperties e unevaluatedItems sono le versioni consapevoli della composizione di additionalProperties e additionalItems. Il perché sta più sotto, perché è questa coppia che si sbaglia.

$dynamicRef e $dynamicAnchor sostituiscono $recursiveRef e $recursiveAnchor del 2019-09. A meno che tu non scriva schemi ricorsivi estensibili, un tipo albero che il chiamante può specializzare, non li toccherai mai. Se hai ereditato uno schema 2019-09 che usa $recursiveRef, sotto un validatore 2020-12 non funziona.

Il punto d’ingresso di Ajv che fallisce in silenzio

L’export predefinito di Ajv implementa solo draft-07. I dialetti 2020-12 e 2019-09 vivono in punti d’ingresso separati. Collegalo nel modo ovvio:

const Ajv = require("ajv");           // draft-07, qualunque cosa dica il tuo $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 non è una parola chiave che questo validatore conosca. Le parole chiave sconosciute vengono ignorate, quindi si controlla soltanto che il valore sia un array di due voci. I tipi degli elementi che hai dichiarato non vengono mai guardati. Ottieni true su dati che il tuo schema vieta esplicitamente.

Ajv qualche difesa ce l’ha. La modalità strict, attiva per impostazione predefinita, protesta davanti a parole chiave sconosciute, e un $schema che nomina un meta-schema non caricato solleva un errore. Entrambe le difese vengono disattivate di routine: strict: false è la prima cosa che si aggiunge quando uno schema usa un vocabolario che Ajv non riconosce, e parecchi schemi generati escono senza $schema del tutto. Togli l’una o l’altra e quello che ottieni è il passaggio silenzioso.

Il collegamento corretto:

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);

Per 2019-09 il punto d’ingresso è ajv/dist/2019. Vale la pena aggiungere un test che affermi che un documento notoriamente sbagliato fallisce. Un impianto di validazione senza test negativo è un impianto che nessuno ha dimostrato funzionare.

format non valida nulla, di suo

In 2019-09 e 2020-12, format è un’annotazione, non un’asserzione. La specifica lo dice esplicitamente. Un validatore conforme che veda {"type": "string", "format": "email"} può annotare «questo valore è stato marcato come email» e poi accettare "non è un’email" senza fiatare. Quasi tutti fanno esattamente così, a meno che tu non attivi l’asserzione.

Quindi questo schema:

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

intercetta un email mancante e intercetta un email che è un numero. Non intercetta "email": "banana" e non intercetta "created_at": "ieri", a meno che al validatore non sia stato detto di asserire i formati.

In Ajv questo significa due cose, non una: installare e registrare ajv-formats, che fornisce le implementazioni vere, e usare il punto d’ingresso del tuo dialetto perché sia in gioco il vocabolario giusto. Ajv non porta con sé alcuna implementazione di formato. Senza ajv-formats, un format è o un errore di formato sconosciuto in modalità strict o un’operazione nulla con strict disattivo. Nessuna delle due è validazione.

Se la forma di un valore conta davvero, sostieni il format con un pattern, oppure con minLength e maxLength. Un’espressione regolare viene asserita da ogni validatore su ogni draft, senza plugin e senza flag. format è documentazione che alcuni validatori si possono convincere ad applicare.

Le date sono la vittima abituale qui, dato che JSON non ha un tipo data e ogni timestamp nel tuo payload in realtà è solo una stringa.

additionalProperties contro unevaluatedProperties

additionalProperties conosce soltanto properties e patternProperties nello stesso oggetto schema. Non vede nulla di ciò che un $ref o un ramo allOf ha portato dentro. Questa singola frase spiega quasi ogni segnalazione del tipo «il mio schema rifiuta un campo che è chiaramente definito»:

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

Ogni proprietà definita in base viene ora rifiutata, perché a questo livello l’unica proprietà nota è role. Sostituisci l’ultima riga con "unevaluatedProperties": false e la parola chiave gira dopo che gli applicatori in loco hanno fatto il loro lavoro, vede tutto ciò che base ha valutato e rifiuta solo ciò di cui nulla ha reso conto.

Regola pratica: un oggetto autonomo con tutte le proprietà dichiarate localmente prende additionalProperties: false. Qualsiasi cosa composta con allOf, $ref, if/then o oneOf prende unevaluatedProperties: false. unevaluatedItems è la stessa relazione per gli array e si accompagna naturalmente a prefixItems.

required significa presente, non valorizzato

required è un elenco di chiavi che devono esistere. Non è altro che questo.

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

{"user_id": null} lo soddisfa. Anche {"user_id": ""}. Se null non è accettabile, dillo nello schema della proprietà, perché "type": "string" esclude null da solo e ["string", "null"] lo ammette:

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

L’errore speculare è marcare come non obbligatorio un campo che può essere null. Una chiave a volte assente e una chiave a volte null sono due contratti diversi per il consumatore, e sceglierne uno di proposito fa parte del progetto della risposta.

Generare uno schema dai campioni

Scrivere a mano uno schema per un payload da quaranta campi è abbastanza noioso da far desistere. Generarne uno da una risposta reale è il punto di partenza pratico, con un requisito che decide se il risultato è usabile.

Un array di oggetti va fuso su ogni elemento, non campionato dal primo. Prendi solo l’elemento zero e due cose vanno storte: una chiave che compare dall’elemento tre in poi manca del tutto da properties, e una chiave che per caso è presente nell’elemento zero ma poi non c’è più viene marcata required, così lo schema rifiuta dati che sai essere validi. Il comportamento corretto è l’unione delle chiavi per properties e l’intersezione per required, con i tipi uniti chiave per chiave.

È ciò che fa il generatore di schemi. Dagli in pasto una pagina di output reale dell’API anziché un esempio scritto a mano, poi modifica il risultato: stringi le stringhe che hanno formati noti, aggiungi enum dove l’insieme è chiuso, e decidi la questione di additionalProperties oggetto per oggetto. L’output generato è una bozza, non un contratto.

Anche i numeri meritano un’occhiata. Un generatore vede 9007199254740993 e scrive "type": "integer", il che è onesto sul tipo e muto sul fatto che JavaScript non può contenere quel valore.

Dove lo sforzo rende

I contratti di API, dove lo schema è la cosa contro cui il produttore testa e con cui il consumatore valida, così che una modifica incompatibile fallisca nella CI e non nei log di un cliente. La validazione della configurazione, dove un refuso in un file di deploy diventa un errore con un JSON Pointer alla chiave incriminata anziché una dereferenza di null tre servizi più in là. E la generazione di codice, dove una definizione produce sia la documentazione di riferimento sia i tipi TypeScript, così che i tre non possano allontanarsi.

Niente di tutto ciò arriva scrivendo uno schema. Arriva scrivendo uno schema che un validatore collegato correttamente applica davvero.