In meinem vorigen Blogeintrag „Laufzeitvalidierung mit Zod“ habe ich dir Zod vorgestellt, eine Bibliothek zur Laufzeitvalidierung im TypeScript-Ökosystem. In diesem zweiten Teil dieser Blogserie zeige ich in an einem konkreten Anwendungsbeispiel, wie du Zod verwenden kannst. Und zwar geht es um die effektive Absicherung der Kommunikation zwischen Front- und Backend.

Worum geht es?

Für Fullstack-Anwendungen ist es im Business-Kontext üblich, ein REST-Backend mit Spring Boot oder Quarkus zu implementieren und an eine Single-Page-Application zu verknüpfen. Die SPA kann zum Beispiel mit Angular implementiert sein. Dies hat zur Folge, dass das Frontend über die REST-Schnittstelle mit dem Backend kommuniziert und so JSON-Daten sendet und empfängt.

Nun könntest du im Frontend die Zod-Schemas „manuell“ definieren. Für Backends, die nur sehr selten aktualisiert werden, ist dies eine valide Lösung. Doch für Anwendungen, welche aktiv entwickelt werden und viele Releases haben, ist diese Herangehensweise fehleranfällig.

Wie wäre es, wenn wir ein Zod Schema automatisch aus dem Backend ableiten könnten?

Von Java POJOs zum Zod Schema

Es gibt keine direkte Lösung, um von einer Java Klassendefinitionen ein Zod Schema abzuleiten. Es gibt jedoch einen indirekten Weg zu Lösung: Über ein Plugin, mit dem wir ein JSON Schemas im Java Kontext generieren können. Dies kann mit einer Bibliothek verknüpft werden, mit welcher ein Zod Schemas aus einem JSON Schema im TypeScript-Kontext generieren werden kann. Denn diese beiden Dinge sind vorhanden.

Exkurs: JSON Schema

Das JSON Schema ist eine Spezifikation mit der wir die erwartete Struktur einer JSON (oder YAML)-Datei definieren. Das Schema wird als JSON definiert.

Das JSON Schema, das {"name": "Waldemar", "id": 1234} validiert, könnte beispielsweise wie folgt aussehen:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "name": {
      "type": "string"
      "minLength": 1
    },
    "id": {
      "type": "int",
      "minimum": 0
    }
  },
  "required": [
    "name",
    "id"
  ]
}

Das JSON Schema hat somit eine ähnliche Aufgabe wie eine OpenAPI Spec. Wobei sich das JSON Schema auf ein JSON-Objekt beschränkt, während die OpenAPI Spec den Vertrag für eine ganze API abbildet.

Von Java POJOs zum JSON Schema

Mithilfe eines Maven-Plugins (jsonschema-maven-plugin aus der com.github.victools Gruppe) werden zum generate-Step Definitionen für alle DTOs JSON-Schema generiert.

Für das Beispiel wurden folgende Abhängigkeiten eingebunden:

<!-- JSON Schema Dependencies -->
<dependency>
    <groupId>com.github.victools</groupId>
    <artifactId>jsonschema-generator</artifactId>
    <version>4.31.1</version>
</dependency>
<dependency>
    <groupId>com.github.victools</groupId>
    <artifactId>jsonschema-module-jackson</artifactId>
    <version>4.31.1</version>
</dependency>
<dependency>
    <groupId>com.github.victools</groupId>
    <artifactId>jsonschema-module-jakarta-validation</artifactId>
    <version>4.31.1</version>
</dependency>

Die zwei letzten Abhängigkeiten sorgen dafür, dass Informationen aus den Annotationen von Jackson und Jakarta auch im JSON Schema abgebildet werden.

Folgende Java-Klasse

import com.fasterxml.jackson.annotation.JsonClassDescription;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;

@JsonClassDescription("A very helpful description!")
public class SumResponseDTO {

  @NotNull
  @DecimalMin("00.00")
  private final double totalProfit;
  // ...
}

wird somit beispielsweise in folgendes Schema umgewandelt:

{
  "type" : "object",
  "properties" : {
    "totalProfit" : {
      "type" : "number",
      "minimum" : 0.00
    },
    // ...
  },
  "required" : [ "totalProfit" ],
  "additionalProperties" : false,
  "description" : "A very helpful description!",
}

Die genaue Konfiguration findet sich im Git Repo in der pom.xml.

Eine Anleitung auf Baeldung.com geht hier nochmal genauer auf die einzelnen Konfigurationsmöglichkeiten ein. Unser Beispiel basiert ebenfalls auf dieser Anleitung.

Kleine Anmerkung: Der Weg von Java POJO zu JSON Schema wäre auch anders herum möglich, alsovon einem JSON Schema zu Java DTO Stubs. Dies ist bei der „API First“-Herangehensweise üblich.

Von JSON Schema zum Zod Schema

Für diesen Schritt wurden die json-schema-to-zod– und json-refs-Bibliotheken verwendet. json-schema-to-zod konsumiert das JSON Schema und gibt einen String zurück, der das Zod Schema abbildet und in eine Datei geschrieben werden kann. json-refs wird hierbei verwendet, um Referenzen innerhalb des JSON Schemas aufzulösen.

// Tupel-Array mit dem Namen und Zod Schema
const zodifiedData = await Promise.all(
  jsonFiles.map(async (filePath) => [
    basename(filePath).replace('.json', ''),
    jsonSchemaToZod(
      await resolveRefs(JSON.parse(readFileSync(filePath, 'utf8')))
    ),
  ])
);

// Dateieinhalt wird zusammengeschrieben. z aus dem zod
// Modul wird importiert.
const moduleText = [
  "import {z} from 'zod';",
  ...zodifiedData.map(
    ([name, content]) => `
  export const ${name}Zod = ${content}; 
  export type ${name} = z.infer<typeof ${name}Zod>;
  `
  ),
].join('\n');

Der Inhalt wird mithilfe eines Build-Skriptes in eine TypeScript-Datei geschrieben.
Das Frontend kann dann die Schemas einfach importieren.

import { WeatherDataResponseDTOZod } from '../../generated/backendTypes';
// ...
const result = await fetch('...')
  .then((e) => e.json())
  .then((e) => WeatherDataResponseDTOZod.parse(e))
  .catch((err) => console.log(err));

Verhalten bei Updates

Wird das Interface des Backends angepasst, werden die JSON Schema-Dateien beim nächsten Build neu generiert.

So lange das Build-Skript nicht getriggert wurde, wird das Frontend für die geänderten Endpunkte Fehler werfen, weil die Antwort nicht dem erwarteten Schema entspricht. Wurde das Build-Skript durchlaufen, und dadurch die Zod Schemas angepasst, hören die Fehler wegen dem unerwarteten Schema auf.

Ggf. kommt es durch die Anpassung zu Fehlern beim Kompilieren durch tsc. Dadurch wird das neue aktualisierte Schema auch automatisch auf die generierten TypeScript-Typen abgebildet. Die Entwicklungsumgebung hilft uns bei neuen Änderungen mit Autovervollständigungen.

Fazit

Zod, sowie das darum liegende Ökosystem an anbindenden Plugins, kann verwendet werden um automatisch TypeScript-Typen und Laufzeit-Validatoren aus Java Klassendefinitionen abzuleiten. Dadurch ist es möglich, REST-APIs automatisch zu typisieren und somit mit wenig Mehraufwand viel mehr Sicherheit und Sauberkeit in die Codebase zu bringen.

Richtig angewendet kann dadurch der zur Compile-Zeit erwartete Zustand und der zur Laufzeit entstandene Zustand „synchronisiert“ werden. Dadurch können wir nach dem Aufruf des Validators davon ausgehen, das der Typ, den TypeScript uns Vorschlägt, auch tatsächlich zur Laufzeit eingetreten ist.

Profilbild Waldemar Lehner
Alle Beiträge von Waldemar Lehner

Associate Developer mit Fokus auf Web-Technologien

Schreibe einen Kommentar