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.
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.coerceto 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 tylkoresult.datadotrzeć 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.coercedla 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
- Zod vs express-validator: Dwie podejścia do walidacji w Expressie — Porównuje walidację żądań opartą na schematach z Zod z interfejsem middleware express-validator bazującym na łańcuchach, omawiając konfigurację, formatowanie błędów oraz częste pułapki.
- Dzielenie sobą jednym schematem Zod między frontendem w React a backendem w Node — Dowiedz się, jak jeden schemat Zod może walidować formularze w React, odpowiedzi API, ciała żądań w Express oraz zmienne środowiskowe, jednocześnie generując odpowiadające im typy TypeScript.