Zum Inhalt springen
jsonbeautifiers
Deutsch

JSON Schema: die Teile, die entscheiden, ob die Validierung wirkt

Die meisten kaputten JSON-Schema-Setups werfen nichts. Sie melden gültig für Daten, die falsch sind.

Jede Aussage auf dieser Seite ist entweder gemessen oder belegt. Wo sie keines von beidem ist, steht das dabei.

Eine Syntaxprüfung sagt Ihnen, dass ein Dokument parst. Sie sagt nichts darüber, ob {"user_id": null, "email": 4} etwas ist, das Ihr Dienst annehmen sollte. Für diese Lücke ist ein Schema da, und es lohnt sich, den Unterschied genau zu benennen: Der Validator beantwortet „ist das wohlgeformtes JSON“, ein Schema beantwortet „ist das das Dokument, das mir versprochen wurde“.

Der Fehlermodus, der echte Zeit kostet, ist nicht ein Schema, das gute Daten ablehnt. Das fällt binnen Minuten auf. Es ist ein Schema, das schlechte Daten annimmt, klaglos kompiliert und ein Jahr lang valid meldet.

Einen Draft wählen

Es gibt zwei Antworten, und beide sind vertretbar.

2020-12 für neue Arbeit. Es ist der Bezeichner, an dem sich OpenAPI 3.1 ausrichtet; wenn Sie also eine API-Beschreibung schreiben, sind Sie ohnehin in diesem Dialekt. Zur Benennung: 2020-12 ist der Zeitpunkt, zu dem der Draft geschnitten wurde, angekündigt wurde er Anfang 2021. Die Dokumente unter jenem URI wurden zuletzt im Juni 2022 neu veröffentlicht, was ein Patch am Spezifikationstext war und keine neue Ausgabe.

draft-07 für die breiteste Werkzeugunterstützung. Er hat den längsten Schwanz an Unterstützung über Sprachen, Editoren und Codegeneratoren hinweg, und viele Validatoren außerhalb des JavaScript-Ökosystems behandeln ihn weiterhin als Vorgabe. Wenn Ihr Schema von Werkzeugen konsumiert werden muss, die Sie nicht kontrollieren, ist draft-07 der pragmatische Boden.

Was Sie nicht tun sollten, ist $schema wegzulassen und zu hoffen. Ein Schema ohne deklarierten Dialekt wird nach dem interpretiert, was der Validator gerade als Vorgabe gewählt hat, und das ist genau die Mehrdeutigkeit, die Sie beseitigen wollten.

Die Änderungen von 2020-12, die Sie tatsächlich anfassen

Drei, nach Häufigkeit ihrer Bisse geordnet.

prefixItems ersetzt die Array-Form von items. In draft-07 bedeutete items mit einem Array-Wert positionsbezogene Tupelvalidierung, und additionalItems schränkte den Rest ein. In 2020-12 gehören Positionen in prefixItems, und items ist immer ein einzelnes Schema, das für alles gilt, was prefixItems nicht abdeckt.

unevaluatedProperties und unevaluatedItems sind die kompositionsbewussten Fassungen von additionalProperties und additionalItems. Warum, steht weiter unten, denn dieses Paar macht man falsch.

$dynamicRef und $dynamicAnchor ersetzen $recursiveRef und $recursiveAnchor aus 2019-09. Wenn Sie keine erweiterbaren rekursiven Schemata schreiben, also einen Baumtyp, den ein Aufrufer spezialisieren kann, werden Sie diese nie anfassen. Wenn Sie ein 2019-09-Schema geerbt haben, das $recursiveRef benutzt: Unter einem 2020-12-Validator funktioniert es nicht.

Der Ajv-Einstiegspunkt, der still versagt

Der Standardexport von Ajv implementiert nur draft-07. Die Dialekte 2020-12 und 2019-09 liegen an eigenen Einstiegspunkten. Verdrahten Sie es auf die naheliegende Weise:

const Ajv = require("ajv");           // draft-07, was auch immer Ihr $schema sagt
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 ist kein Schlüsselwort, das dieser Validator kennt. Unbekannte Schlüsselwörter werden ignoriert, geprüft wird also nur, dass der Wert ein Array mit zwei Einträgen ist, sonst nichts. Die von Ihnen deklarierten Elementtypen werden nie angesehen. Sie bekommen true für Daten, die Ihr Schema klar verbietet.

Ajv hat durchaus Schutzmechanismen. Der Strict-Modus, standardmäßig an, beanstandet unbekannte Schlüsselwörter, und ein $schema, das ein nicht geladenes Meta-Schema nennt, wirft einen Fehler. Beide Schutzmechanismen werden routinemäßig abgeschaltet: strict: false ist das Erste, was man ergänzt, wenn ein Schema ein Vokabular verwendet, das Ajv nicht kennt, und reichlich generierte Schemata kommen ganz ohne $schema. Nehmen Sie einen der beiden weg, und Sie bekommen den stillen Durchlauf.

Die richtige Verdrahtung:

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

Für 2019-09 lautet der Einstiegspunkt ajv/dist/2019. Es lohnt sich, einen Test zu ergänzen, der behauptet, dass ein bekannt schlechtes Dokument scheitert. Ein Validierungs-Setup ohne Negativtest ist ein Setup, von dem niemand bewiesen hat, dass es funktioniert.

format validiert standardmäßig gar nichts

In 2019-09 und 2020-12 ist format eine Annotation, keine Zusicherung. Die Spezifikation sagt das ausdrücklich. Ein konformer Validator, der {"type": "string", "format": "email"} sieht, darf festhalten „dieser Wert wurde als E-Mail annotiert“ und dann "keine E-Mail" klaglos annehmen. Die meisten tun genau das, sofern Sie die Zusicherung nicht einschalten.

Dieses Schema also:

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

fängt eine fehlende email und fängt eine email, die eine Zahl ist. Es fängt nicht "email": "Banane" und es fängt nicht "created_at": "gestern", sofern man dem Validator nicht gesagt hat, Formate zuzusichern.

In Ajv heißt das zweierlei, nicht einerlei: ajv-formats installieren und registrieren, das die eigentlichen Implementierungen liefert, und den Einstiegspunkt Ihres Dialekts verwenden, damit das richtige Vokabular im Spiel ist. Ajv bringt keine eigenen Formatimplementierungen mit. Ohne ajv-formats ist ein format entweder ein Unknown-Format-Fehler im Strict-Modus oder ein Nichts, wenn Strict aus ist. Beides ist keine Validierung.

Wenn die Form eines Werts wirklich zählt, untermauern Sie das format mit einem pattern oder mit minLength und maxLength. Ein regulärer Ausdruck wird von jedem Validator in jedem Draft zugesichert, ohne Plugin und ohne Flag. format ist Dokumentation, zu deren Durchsetzung manche Validatoren überredet werden können.

Datumsangaben sind hier das übliche Opfer, denn JSON hat keinen Datumstyp, und jeder Zeitstempel in Ihrem Payload ist in Wahrheit nur eine Zeichenkette.

additionalProperties gegen unevaluatedProperties

additionalProperties weiß nur von properties und patternProperties im selben Schemaobjekt. Es kann nichts sehen, was ein $ref oder ein allOf-Zweig hereingebracht hat. Dieser eine Satz erklärt fast jeden Fehlerbericht der Sorte „mein Schema lehnt ein Feld ab, das eindeutig definiert ist“:

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

Jede in base definierte Eigenschaft wird nun abgelehnt, denn auf dieser Ebene ist die einzige bekannte Eigenschaft role. Ersetzen Sie die letzte Zeile durch "unevaluatedProperties": false, und das Schlüsselwort läuft, nachdem die In-Place-Applikatoren ihre Arbeit getan haben, sieht alles, was base ausgewertet hat, und lehnt nur ab, wofür nichts aufgekommen ist.

Faustregel: Ein eigenständiges Objekt mit allen lokal deklarierten Eigenschaften nimmt additionalProperties: false. Alles, was mit allOf, $ref, if/then oder oneOf zusammengesetzt ist, nimmt unevaluatedProperties: false. unevaluatedItems ist dieselbe Beziehung für Arrays und passt naturgemäß zu prefixItems.

required heißt vorhanden, nicht befüllt

required ist eine Liste von Schlüsseln, die existieren müssen. Mehr ist es nicht.

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

{"user_id": null} erfüllt das. {"user_id": ""} ebenso. Wenn null nicht akzeptabel ist, sagen Sie das im Schema der Eigenschaft, denn "type": "string" schließt null von sich aus aus, und ["string", "null"] lässt es zu:

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

Der Spiegelfehler ist, ein nullbares Feld als nicht erforderlich zu markieren. Ein Schlüssel, der manchmal fehlt, und ein Schlüssel, der manchmal null ist, sind zwei verschiedene Verträge für den Konsumenten, und einen davon bewusst zu wählen gehört zum Entwurf der Antwort.

Ein Schema aus Stichproben erzeugen

Ein Schema für ein Payload mit vierzig Feldern von Hand zu schreiben ist mühsam genug, dass man es sein lässt. Eines aus einer echten Antwort zu erzeugen ist der praktische Ausgangspunkt, mit einer Anforderung, die darüber entscheidet, ob das Ergebnis brauchbar ist.

Ein Array von Objekten muss über alle Elemente zusammengeführt werden, nicht aus dem ersten abgetastet. Nehmen Sie nur Element null, gehen zwei Dinge schief: Ein Schlüssel, der erst ab Element drei auftaucht, fehlt in properties ganz, und ein Schlüssel, der zufällig in Element null vorhanden ist, später aber nicht, wird als required markiert, sodass das Schema Daten ablehnt, von denen Sie wissen, dass sie gültig sind. Richtig ist die Vereinigung der Schlüssel für properties und der Durchschnitt für required, mit je Schlüssel vereinigten Typen.

Genau das macht der Schema-Generator. Füttern Sie ihn mit einer Seite echter API-Ausgabe statt mit einem handgetippten Beispiel, und bearbeiten Sie dann das Ergebnis: Ziehen Sie die Zeichenketten mit bekannten Formaten enger, ergänzen Sie enum, wo die Menge geschlossen ist, und entscheiden Sie die additionalProperties-Frage je Objekt. Erzeugte Ausgabe ist ein Entwurf, kein Vertrag.

Auch Zahlen verdienen einen Blick. Ein Generator sieht 9007199254740993 und schreibt "type": "integer", was über den Typ ehrlich ist und darüber schweigt, dass JavaScript diesen Wert nicht halten kann.

Wo sich der Aufwand auszahlt

API-Verträge, bei denen das Schema das ist, wogegen der Erzeuger testet und womit der Konsument validiert, sodass eine brechende Änderung in der CI scheitert statt in den Logs eines Kunden. Konfigurationsvalidierung, bei der ein Tippfehler in einer Deployment-Datei zu einem Fehler mit JSON-Pointer auf den schuldigen Schlüssel wird statt zu einer Null-Dereferenzierung drei Dienste tiefer. Und Codegenerierung, bei der eine Definition sowohl die Referenzdokumentation als auch die TypeScript-Typen erzeugt, sodass die drei nicht auseinanderdriften können.

Nichts davon kommt davon, ein Schema zu schreiben. Es kommt davon, ein Schema zu schreiben, das ein korrekt verdrahteter Validator tatsächlich durchsetzt.