Strona główna / Artykuły / Ochrona granicy Express: jeden middleware Zod dla ciała żądania, parametrów i zapytania

Ochrona granicy Express: jeden middleware Zod dla ciała żądania, parametrów i zapytania

Dowiedz się, jak weryfikować treści żądań Express, parametry ścieżek oraz łańcuchy zapytań za pomocą jednego wielokrotnie używalnego middleware Zod oraz jak uzupełnia on weryfikację modeli Sequelize.

1982 słów

Nic nie powstrzymuje klienta przed podaniem numeru tam, gdzie twoja API oczekuje nazwy, lub null tam, gdzie oczekuje hasła. Kod, który ślepo ufa req.body, ostatecznie tworzy uszkodzone rekordy lub wywołuje błędy o przyczynie dalekiej od rzeczywistej. Ten przewodnik pokazuje, jak raz opisać ważne dane za pomocą Zod, wdrożyć te wymagania w jednym middleware Express obejmującym treść żądania, parametry trasy oraz ciąg zapytania, a także zachować koncentrację kontrolerów na logice biznesowej.

Problem: żądania przychodzą bez określenia typów

Oto zupełnie legalna zawartość HTTP, której żaden punkt końcowy rejestracji nie powinien przyjmować:

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

Każde pole ma niewłaściwą strukturę. Zod to biblioteka schematów dla JavaScript i TypeScript, która pozwala dokładnie określić, czego się oczekuje, i zwrócić albo czyste dane, albo ustrukturyzowaną listę problemów.

Opisywanie danych wejściowych jako schematu

Załóżmy, że rejestracja wymaga ciągu znaków fullName, poprawnie sformatowanego adresu e-mail email, hasła składającego się co najmniej z ośmiu znaków oraz opcjonalnego całkowitego liczby age. W Zod to wygląda niemal jak same wymagania:

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

Zasady znajdują się w jednym obiekcie zamiast być rozproszone w instrukcjach if. Zasada dotycząca wieku nakłada również wymóg co najmniej 18 lat: brak podania wieku skutkuje powodzeniem, natomiast wiek 16 lat powoduje niepowodzenie.

Instalacja i import

Zod to zwykła zależność npm:

npm install zod

W CommonJS należy zaimportować przestrzeń nazw z za pomocą require:

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

W modułach ES należy użyć importu oznaczonego nazwą:

import { z } from 'zod';

Dodawanie czytelnych komunikatów o błędach

Każdy walidator przyjmuje opcjonalny komunikat, który będzie widoczny dla klienta:

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

Ładunek, który spełnia wszystkie zasady, przechodzi bez zmian:

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

W tym przypadku nazwa jest zbyt krótka, adres nie zawiera domeny, a hasło składa się z trzech znaków:

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

Zod zgłasza wszystkie trzy problemy jednocześnie, dzięki czemu formularz może zaznaczyć każde nieprawidłowe pole w jednej transakcji.

Nowsze wersje Zod (v4 i nowsze) oferują również walidatory najwyższego poziomu, takie jak z.email(), i deprecjonują styl łańcuchowy z.string().email(). Taki łańcuch nadal działa, ale sprawdź aktualną dokumentację dla swojej wersji.

Wybór między parse() a safeParse()

parse() rzuca błąd

parse() zwraca zweryfikowane dane lub rzuca ZodError:

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

W obsłudze Express musisz sam złapać błąd lub przekazać go za pomocą next(err).

safeParse() zwraca wynik

safeParse() nigdy nie rzuca błędem. Zwraca obiekt z flagą success, co lepiej pasuje do obsługi żądań, ponieważ nieważne dane są wynikiem oczekiwanym, a nie wyjątkiem:

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

W przypadku błędu error.issues wymienia każdy problem wraz z jego ścieżką i komunikatem, gotowy do odpowiedzi 400:

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

W przypadku sukcesu result.data zawiera przetworzoną wartość:

const data = result.data;

Od teraz używaj result.data, a nie req.body: klucze nieznane są domyślnie usuwane, a konwersje i wartości domyślne zostały już zastosowane.

Od wewnętrznych sprawdzeń do wielokrotnie używalnego middleware

Najprostsza integracja wywołuje safeParse() wewnątrz obsługownika:

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

Funkcjonuje to, ale przy 20 lub 50 punktach końcowych te same linie są wklejane do każdego kontrolera, co powoli prowadzi do rozbieżności. Należy również zauważyć, że ten przykład odsyła do klienta zweryfikowany obiekt, włączając hasło; prawdziwy punkt końcowy powinien zwracać tylko pola niesensywne.

Fabryka validate()

Poniższa fabryka przyjmuje schemat i zwraca obsługę Express. Weryfikuje treść żądania, parametry oraz zapytanie jednocześnie, w przypadku błędu zwraca kod 400, a w przeciwnym razie przechowuje zinterpretowane wyniki w req.validated przed wywołaniem next():

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;

Dwa szczegóły są istotne. Zapisywanie danych do oddzielnej właściwości req.validated zapobiega problemom w Express 5, gdzie req.query jest funkcją pobierającą dane i nie można jej po prostu przypisać nowej wartości. Ponadto, ponieważ middleware pakuje dane wejściowe jako { body, params, query }, schematy muszą odpowiadać temu kształtowi. Prosty registerSchema szukałby pola fullName na najwyższym poziomie i odrzucałby każdą prośbę, dlatego należy je zapakować jako z.object({ body: registerSchema }) lub sprawić, by middleware weryfikował tylko req.body.

Podłączenie do trasy

Middleware znajduje się pomiędzy ścieżką a kontrolerem:

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

Łańcuch przetwarzania żądań wygląda następująco:

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

Nieważne dane wejściowe zostają zatrzymane na poziomie middleware, więc kontroler nigdy nie jest uruchamiany; ważne dane wejściowe są dalej przetwarzane, a dane gwarantują zgodność ze schematem.

Zachowanie kontrolerów zgodnie z logiką biznesową

Bez warstwy walidacji kontroler gromadzi wszystkie problemy naraz:

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

Z użyciem middleware kontroler jedynie odczytuje zweryfikowane wartości:

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

Jako dodatkową zaletę schematy mogą być testowane jednostkowo przy użyciu zwykłych obiektów, a testy kontrolerów nie wymagają już przypadków dla każdego błędnego ciężaru danych.

Walidacja parametrów trasy z przymusem konwersji

Ta sama metoda odnosi się do segmentów URL. Weźmy prośbę o jednego użytkownika:

GET /users/123

Schemat dla parametru id:

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

Przyłączony jak wcześniej (pod kluczem params przy użyciu powyższego middleware):

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

Kluczowym elementem jest przymus konwersji:

z.coerce.number()

Wszystko w URL to tekst. Wartość

req.params.id

przychodzi jako ciąg znaków

"123"

a nie jako liczba

123

Zwykła funkcja z.number() odrzuciłaby każdą prośbę. Funkcja z.coerce.number() najpierw przekazuje dane przez funkcję Number(), a następnie stosuje metody .int() i .positive(). Istnieje jeden przypadek krawędziowy: Number('') zwraca wartość 0, więc pusta wartość staje się zerem. W tym przypadku metoda .positive() to łapie, ale schemat bez ograniczenia dolnego pozwoliłby na jej przekazanie.

Paginacja to klasyczny przykład użycia ciągu zapytań:

GET /users?page=1&limit=10

Konwersja w połączeniu z domyślnymi wartościami zapewnia bezpieczne liczby, nawet gdy klient je pominie:

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

Ograniczenie .max(100) również zapobiega temu, by klient zażądał miliona wierszy w jednej prośbie.

Większość schematów składa się z niewielkiej liczby elementów:

  • z.string(), z.number(), z.boolean() sprawdzają typy prymitywne.
  • z.object() opisuje strukturę obiektu; z.array() weryfikuje tablicę oraz jej elementy.
  • z.enum() ogranicza wartość do ustalonej listy opcji.
  • .min() i .max() określają granice wartości liczbowej lub długości ciągu znaków lub tablicy.
  • .email() sprawdza format adresu e-mail; .int() wymaga liczby całkowitej; .positive() wymaga wartości większej od zera.
  • .optional() dopuszcza brak pola; .nullable() dopuszcza wartość null; .default() uzupełnia brakujące wartości.
  • z.coerce to przestrzeń nazw, a nie funkcja: z.coerce.number() i podobne funkcje konwertują dane wejściowe przed weryfikacją.
  • .refine() dodaje niestandardowe reguły; .transform() zmienia kształt wartości po jej przetworzeniu.
  • .parse() wywołuje błąd w przypadku niepowodzenia; .safeParse() zwraca wynik o statusie sukcesu lub błędu.
  • Przykład: rekord użytkownika z rolami

    Użytkownik w aplikacji do zarządzania żłobkiem może wyglądać w ten sposób:

    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() odrzuca wszelkie inne role, a isActive ma wartość domyślną true, jeśli nie została określona. Schemat pełni również rolę dokumentacji.

    Zod i Sequelize walidują różne warstwy

    Zespoły pracujące z Sequelize i MySQL często pytają, dlaczego potrzebują Zod, skoro modele już mają walidatory. Obie te narzędzia chronią różne obszary.

    Zod chroni granicę API

    Sprawdza to, co przychodzi przez HTTP, zanim kod aplikacji na to zadziała:

    HTTP Request
          ↓
         Zod
          ↓
     Controller
    

    Sequelize chroni warstwę danych

    Jego walidatory uruchamiają się podczas zapisywania modelu, głęboko w warstwie usług:

    Controller
         ↓
     Service
         ↓
     Sequelize
         ↓
     MySQL
    

    Użycie obu

    Razem tworzą one dwie niezależne warstwy:

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

    Zod dostarcza szybkich, przyjaznych dla klienta odpowiedzi 400; Sequelize wykrywa błędy pochodzące z wnętrza aplikacji, takie jak praca w tle tworząca błędny rekord. Ograniczenia bazodanowe, takie jak NOT NULL i unikalne indeksy, pozostają ostateczną barierą bezpieczeństwa.

    Organizacja schematów w większej bazie kodu

    W projekcie opartym na modułach każdy moduł ma plik walidacji obok swoich tras, kontrolerów i usług, a wspólne middleware znajduje się w osobnej folderze:

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

    user.validation.js eksportuje schematy modułu:

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

    a plik z trasami pozostaje krótki:

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

    Gdy zmienia się pole, kontroler wraz z jego regułami jest edytowany jednocześnie. Aby ponownie wykorzystać te same schematy w przeglądarce, zobacz dzielenie się jednym schematem Zod między React a Node.

    Dlaczego pojedyncze źródło prawdy jest korzystne

    Bez schematu walidacja przenika do kontrolerów w postaci ad hoc sprawdzeń:

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

    Każdy punkt końcowy zawiera nieco inną wersję, więc nikt nie może od razu zobaczyć pełnego kontraktu. Odpowiedni schemat opisuje to w kilku liniach:

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

    To jest umowa pomiędzy API a klientami, realizowana w jednym miejscu. Aby porównać to z innym popularnym podejściem, zobacz Zod versus express-validator.

    Główne wnioski

    Prawdziwa wartość leży w kolejności obowiązków, które narzuca Zod:

    Request
       ↓
    Validation
       ↓
    Controller
       ↓
    Business Logic
       ↓
    Database
    
    • Waliduj na granicy za pomocą safeParse() i pozwól tylko result.data dotrzeć do obsługujących funkcji.
    • Zcentralizuj walidację w jednym middleware i upewnij się, że każdy schemat odpowiada kształtowi danych, które parsuje.
    • Używaj z.coerce dla parametrów i ciągów zapytań oraz ogranicz wartości, takie jak rozmiar strony.
    • Zachowaj walidatory ORM i ograniczenia bazy danych jako drugą warstwę, a nie ich zamiennik.
    • Rozmieść schematy obok swoich modułów, aby zmiany w kontrakcie szły w parze z kodem.

    Literatura pokrewna

  • Identyfikacja vs Kształtowanie: Wybór parametrów trasy lub ciągów zapytań w Express — Dowiedz się, kiedy wartość powinna znajdować się w parametrze trasy Express, a kiedy w ciągu zapytania, jak odczytywać req.params i req.query oraz jak bezpiecznie obsługiwać wartości domyślne i typy.
  • Metoda HTTP QUERY dla zespołów frontend: Bezpieczne odczyty z ciałem wiadomości — Dowiedz się, kiedy metoda HTTP QUERY jest lepsza od GET i POST przy złożonych filtrach, jak używać jej z fetch oraz jakie wymagania stawia CORS, cacheowanie i infrastruktura.