Startseite / Artikel / Schutz der Express-Grenze: Ein Zod-Middleware für Körper, Parameter und Abfragen

Schutz der Express-Grenze: Ein Zod-Middleware für Körper, Parameter und Abfragen

Erfahren Sie, wie Sie mit einem wiederverwendbaren Zod-Middleware-Modul Anfragedatenkörper, Route-Parameter sowie Abfragesätze in Express validieren können und wie dieses Modul die Validierung von Sequelize-Modellen ergänzt.

1982 Wörter

Nichts hindert einen Client daran, eine Zahl einzusenden, wo Ihre API einen Namen erwartet, oder null, wo ein Passwort erwartet wird. Code, der req.body blind vertraut, führt letztendlich zu fehlerhaften Datensätzen oder wirft Fehler auf, die weit von ihrer tatsächlichen Ursache entfernt sind. Diese Anleitung zeigt, wie man gültige Eingaben einmal mit Zod beschreibt, diese in einem einzigen Express-Middleware-Modul sowohl für den Request-Körper als auch für Route-Parameter und Abfragesätze durchsetzt und die Controller auf die Geschäftslogik konzentriert hält.

Das Problem: Anfragen kommen untypisiert an

Hier ist ein völlig gültiger HTTP-Payload, den kein Registrierungsendpunkt akzeptieren sollte:

{
  "fullName": 123,
  "email": "hello",
  "password": null
}

Jedes Feld hat die falsche Struktur. Zod ist eine Schema-Bibliothek für JavaScript und TypeScript, mit der Sie genau angeben können, was Sie erwarten, und entweder saubere Daten oder eine strukturierte Liste von Fehlern erhalten.

Eingaben als Schema beschreiben

Nehmen wir an, die Registrierung erfordert einen fullName-String, eine korrekt formatierte email-Adresse, ein Passwort mit mindestens acht Zeichen sowie optional ein ganzzahliges Alter. In Zod liest sich das fast wie die Anforderungen selbst:

const { z } = require('zod');

const registerSchema = z.object({
    fullName: z.string().min(2),
    email: z.string().email(),
    password: z.string().min(8),
    age: z.number().int().min(18).optional()
});

Die Regeln befinden sich in einem einzigen Objekt anstelle von verstreuten if-Anweisungen. Die Altersregel legt außerdem ein Mindestalter von 18 fest: Ein fehlendes Alter gilt als gültig, ein Alter von 16 nicht.

Installation und Import

Zod ist eine gewöhnliche npm-Abhängigkeit:

npm install zod

In CommonJS wird der z-Namespace mit require eingebunden:

const { z } = require('zod');

Mit ES-Modulen wird ein benannter Import verwendet:

import { z } from 'zod';

Hinzufügen von verständlichen Fehlermeldungen

Jeder Validator akzeptiert eine optionale Nachricht, die den Benutzern angezeigt wird:

const registerSchema = z.object({
    fullName: z.string().min(2, 'Full name is required'),
    email: z.string().email('Invalid email'),
    password: z
        .string()
        .min(8, 'Password must be at least 8 characters'),
    age: z
        .number()
        .int()
        .min(18)
        .optional()
});

Ein Payload, der alle Regeln erfüllt, wird unverändert übergeben:

{
  "fullName": "John Smith",
  "email": "john@example.com",
  "password": "password123",
  "age": 25
}

Bei diesem Beispiel ist der Name zu kurz, die Adresse enthält keinen Domain-Teil und das Passwort besteht aus nur drei Zeichen:

{
  "fullName": "J",
  "email": "invalid-email",
  "password": "123"
}

Zod meldet alle drei Probleme gleichzeitig, sodass ein Formular bei einer einzigen Anfrage jedes ungültige Feld hervorheben kann.

Neueere Zod-Versionen (v4 und neuer) bieten außerdem Validatoren der obersten Ebene wie z.email() an und deprecieren den verschachtelten z.string().email()-Stil. Die verschachtelte Form bleibt zwar funktionsfähig, aber Sie sollten die aktuellen Dokumentationen für Ihre Version prüfen.

Auswahl zwischen parse() und safeParse()

parse() wirft Ausnahmen aus

parse() gibt die validierten Daten zurück oder wirft eine ZodError-Ausnahme aus:

const data = registerSchema.parse(req.body);

In einem Express-Handler müssen Sie die Ausnahme dann entweder selbst fangen oder sie mit next(err) weiterleiten.

safeParse() gibt ein Ergebnis zurück

safeParse() wirft niemals Ausnahmen. Es gibt ein Objekt mit einem success-Flag zurück, was sich besser für die Anfragenverarbeitung eignet, da ungültige Eingaben ein erwartetes Ergebnis und keine Ausnahme darstellen:

const result = registerSchema.safeParse(req.body);

Im Falle eines Fehlers listet error.issues jedes Problem zusammen mit seinem Pfad und der entsprechenden Meldung auf, sodass eine 400-Antwort bereitgestellt werden kann:

if (!result.success) {
    return res.status(400).json({
        success: false,
        errors: result.error.issues
    });
}

Im Erfolgsfall enthält result.data den parsierten Wert:

const data = result.data;

Verwenden Sie ab jetzt result.data statt req.body: Unbekannte Schlüssel werden standardmäßig entfernt, und Konvertierungen sowie Standardwerte wurden bereits angewendet.

Von inline-Prüfungen zu wiederverwendbarem Middleware

Die einfachste Integration ruft safeParse() innerhalb des Handlers auf:

app.post('/register', (req, res) => {
  const result = registerSchema.safeParse(req.body);
    if (!result.success) {
        return res.status(400).json({
            success: false,
            message: 'Validation failed',
            errors: result.error.issues
        });
    }
    const data = result.data;
    console.log(data);
    // Continue with registration logic...
    return res.status(201).json({
        success: true,
        data
    });
});

Es funktioniert, aber bei 20 oder 50 Endpunkten werden dieselben Zeilen in jeden Controller eingefügt, wodurch sich diese allmählich voneinander unterscheiden. Beachten Sie außerdem, dass dieses Beispiel das validierte Objekt – einschließlich des Passworts – an den Client zurücksendet; ein echter Endpunkt sollte nur nicht sensible Felder zurückgeben.

Eine validate()-Factory

Die untenstehende Factory nimmt ein Schema entgegen und gibt einen Express-Handler zurück. Sie validiert den Request-Körper, die Parameter sowie die Abfragen gemeinsam, gibt bei Fehlern 400 zurück und speichert ansonsten das parsierte Ergebnis in req.validated, bevor sie next() aufruft:

const validate = (schema) => {
    return (req, res, next) => {
      const result = schema.safeParse({
                  body: req.body,
                  params: req.params,
                  query: req.query
              });
              if (!result.success) {
                  return res.status(400).json({
                      success: false,
                      message: 'Validation failed',
                      errors: result.error.issues
                  });
              }
              req.validated = result.data;
              next();
          };
      };

 module.exports = validate;

Zwei Aspekte sind wichtig. Das Schreiben in eine separate Eigenschaft req.validated vermeidet Probleme in Express 5, wo req.query ein Getter ist und daher nicht einfach neu zugewiesen werden kann. Da das Middleware-Modul die Eingaben als { body, params, query } verpackt, müssen die Schemata dieser Struktur folgen. Ein einfaches registerSchema würde nach fullName auf der obersten Ebene suchen und alle Anfragen ablehnen – daher sollte es als z.object({ body: registerSchema }) verpackt werden oder das Middleware-Modul sollte nur req.body validieren.

Einschaltung in eine Route

Das Middleware-Modul befindet sich zwischen dem Pfad und dem Controller:

router.post(
    '/register',
    validate(registerSchema),
    register
);

Der Anfragenpipeline sieht dann so aus:

Request
   ↓
Express Router
   ↓
Zod Validation Middleware
   ↓
Controller
   ↓
Service
   ↓
Database

Ungültige Eingaben werden bereits beim Middleware-Modul gestoppt, sodass der Controller niemals ausgeführt wird; gültige Eingaben werden weitergeleitet, wobei garantiert ist, dass die Daten dem Schema entsprechen.

Controller auf die Geschäftslogik beschränken

Ohne eine Validierungsstufe sammeln Controller alle Aufgaben auf einmal:

const register = async (req, res) => {
    // validation
    // check email
    // validate password
    // validate name
    // business logic
    // database operation
};

Mit dem Middleware-Element liest der Controller lediglich die überprüften Werte:

const register = async (req, res) => {
 const {
        fullName,
        email,
        password
    } = req.validated.body;
    // Business logic
};

Zusätzlich können Schemata mit einfachen Objekten unitgetestet werden, und Controller-Tests benötigen nicht mehr einen Fall für jeden fehlerhaften Payload.

Validierung von Route-Parametern durch Zwangsumwandlung

Der gleiche Ansatz gilt auch für URL-Segmente. Nehmen wir eine Anfrage für einen Benutzer:

GET /users/123

Ein Schema für den id-Parameter:

const userParamsSchema = z.object({
    id: z.coerce.number().int().positive()
});

Wie zuvor angehängt (unter dem Schlüssel params bei Verwendung des oben genannten Middleware-Elements):

router.get(
    '/users/:id',
    validate(userParamsSchema),
    getUser
);

Der entscheidende Punkt ist die Zwangsumwandlung:

z.coerce.number()

Alles in einer URL ist Text. Der Wert von

req.params.id

kommt als Zeichenkette an

"123"

nicht als Zahl

123

Ein einfaches z.number() würde jede Anfrage ablehnen. z.coerce.number() führt den Eingabewert zunächst durch Number() und wendet anschließend .int() sowie .positive() an. Ein Randfall: Number('') ergibt 0, sodass ein leeres Wert als Null behandelt wird. Hier fängt .positive() dies ab, doch ein Schema ohne Untergrenze würde es durchlassen.

Validierung von Abfragesätzen mit Standardwerten

Die Paginierung ist ein klassisches Beispiel für Abfragesätze:

GET /users?page=1&limit=10

Durch Zwangskonvertierung und Standardwerte entstehen sichererweise gültige Zahlen, selbst wenn der Client diese weglässt:

const userQuerySchema = z.object({
    page: z.coerce.number().int().positive().default(1),
    limit: z.coerce.number().int().positive().max(100).default(10)
});

Die Obergrenze .max(100) verhindert außerdem, dass ein Client in einem Aufruf eine Million Zeilen anfordern kann.

Häufige Zod-Bausteine

Die meisten Schemata kombinieren eine kleine Anzahl von Elementen:

  • z.string(), z.number(), z.boolean() überprüfen primitive Datentypen.
  • z.object() beschreibt die Struktur eines Objekts; z.array() prüft ein Array sowie seine Elemente.
  • z.enum() beschränkt einen Wert auf eine feste Liste von Optionen.
  • .min() und .max() begrenzen den Wert einer Zahl oder die Länge eines Strings bzw. Arrays.
  • .email() überprüft das E-Mail-Format; .int() erfordert eine ganze Zahl; .positive() einen Wert über Null.
  • .optional() erlaubt das Fehlen eines Feldes; .nullable() erlaubt explizit null; .default() füllt fehlende Werte aus.
  • z.coerce ist ein Namespace statt eine Funktion: z.coerce.number() und ähnliche Funktionen konvertieren den Eingabewert vor der Überprüfung.
  • .refine() fügt benutzerdefinierte Regeln hinzu; .transform() formt einen Wert um, nachdem er durchgegangen ist.
  • .parse() wirft bei Fehler einen Ausnahmefall aus; .safeParse() gibt ein Ergebnis für Erfolg oder Fehler zurück.
  • Beispiel: ein Benutzerdatensatz mit Rollen

    Ein Benutzer in einer Kindertagesstätten-Verwaltungsapp könnte so aussehen:

    const userSchema = z.object({
        fullName: z.string().min(2),
        email: z.string().email(),
        role: z.enum([
            'admin',
            'teacher',
            'parent'
        ]),
        isActive: z.boolean().default(true)
    });
    

    z.enum() lehnt alle anderen Rollen ab, und isActive hat standardmäßig den Wert true, wenn er weggelassen wird. Das Schema dient gleichzeitig als Dokumentation.

    Zod und Sequelize validieren unterschiedliche Ebenen

    Teams, die Sequelize und MySQL verwenden, fragen oft, warum sie Zod benötigen, wenn die Modelle bereits Validatoren haben. Die beiden schützen unterschiedliche Grenzen.

    Zod schützt die API-Grenze

    Es überprüft, was über HTTP ankommt, bevor der Anwendungscode darauf reagiert:

    HTTP Request
          ↓
         Zod
          ↓
     Controller
    

    Sequelize schützt die Datenschicht

    Ihre Validatoren laufen beim Speichern eines Modells, tief im Service-Schicht:

    Controller
         ↓
     Service
         ↓
     Sequelize
         ↓
     MySQL
    

    Verwendung beider

    Zusammen bilden sie zwei unabhängige Schichten:

    Client
       ↓
    Express
       ↓
    Zod
       ↓
    Controller
       ↓
    Service
       ↓
    Sequelize
       ↓
    MySQL
    

    Zod liefert schnelle, für den Client freundliche 400-Antworten; Sequelize erfasst Fehler, die innerhalb der Anwendung entstehen, wie beispielsweise ein Hintergrundjob, der eine fehlerhafte Datenspur erstellt. Datenbankbeschränkungen wie NOT NULL sowie eindeutige Indizes bleiben die letzte Sicherheitsmaßnahme.

    Organisieren von Schemata in einer größeren Codebasis

    In einem modulbasierten Projekt erhält jedes Modul eine Validierungsdatei neben seinen Routen, Controller und Service, wobei das gemeinsame Middleware in einer eigenen Datei abgelegt wird:

    src/
    ├── modules/
    │   └── users/
    │       ├── user.controller.js
    │       ├── user.service.js
    │       ├── user.routes.js
    │       └── user.validation.js
    │
    ├── middleware/
    │   └── validate.js
    │
    └── app.js
    

    user.validation.js exportiert die Schemata des Moduls:

    const { z } = require('zod');
    
    const createUserSchema = z.object({
        fullName: z.string().min(2),
        email: z.string().email(),
        password: z.string().min(8)
    });
    
    module.exports = {
        createUserSchema
    };
    

    und die Routendatei bleibt kurz:

    router.post(
        '/users',
        validate(createUserSchema),
        createUser
    );
    

    Wenn sich ein Feld ändert, werden der Controller und seine Regeln gemeinsam geändert. Um dieselben Schemata im Browser wiederverwenden zu können, siehe das Teilen eines Zod-Schemas zwischen React und Node.

    Warum eine einzige Quelle der Wahrheit vorteilhaft ist

    Ohne Schema dringen Validierungen als ad hoc Checks in die Controller ein:

    if (!email) {
        // ...
    }
    if (!password) {
        // ...
    }
    if (password.length < 8) {
        // ...
    }
    if (!['admin', 'teacher'].includes(role)) {
        // ...
    }
    

    Jeder Endpunkt wiederholt eine leicht unterschiedliche Version, und niemand kann den vollständigen Vertrag auf einen Blick erkennen. Das entsprechende Schema beschreibt dies in wenigen Zeilen:

    const userSchema = z.object({
        email: z.string().email(),
        password: z.string().min(8),
        role: z.enum(['admin', 'teacher'])
    });
    

    Das ist die Vereinbarung zwischen API und Clients, die an einem Ort durchgesetzt wird. Für einen Vergleich mit einem anderen beliebten Ansatz siehe Zod versus express-validator.

    Haupterkenntnisse

    Der wahre Wert liegt in der Reihenfolge der Verantwortlichkeiten, die Zod vorgibt:

    Request
       ↓
    Validation
       ↓
    Controller
       ↓
    Business Logic
       ↓
    Database
    
    • Überprüfen Sie an der Grenze mit safeParse(), sodass nur result.data die Handler erreicht.
    • Zentralisieren Sie die Validierung in einem Middleware und stellen Sie sicher, dass jedes Schema der von ihm解析ten Struktur entspricht.
    • Verwenden Sie z.coerce für Parameter und Abfragesätze sowie setzen Sie Grenzen für Werte wie die Seitengröße.
    • Behalten Sie ORM-Validatoren und Datenbankbeschränkungen als zweite Schicht bei, nicht als Ersatz.
    • Platzieren Sie Schemata zusammen mit ihren Modulen, damit sich der Vertrag mit dem Code ändert.

    Zusätzliche Literatur

  • Identify vs Shape: Auswahl von Route-Parametern oder Abfragesätzen in Express — Erfahren Sie, wann ein Wert in einen Express-Route-Parameter oder in einen Abfragesatz gehört, wie man req.params und req.query liest sowie wie man Standardwerte und Typen sicher handhabt.
  • Die HTTP QUERY-Methode für Frontend-Teams: Sichere Lesevorgänge mit einem Body — Erfahren Sie, wann die HTTP QUERY-Methode im Vergleich zu GET und POST für komplexe Filter vorteilhaft ist, wie man sie mit fetch aufruft sowie welche CORS-, Caching- und Infrastrukturunterstützung erforderlich ist.