Охорона межі Express: один Zod Middleware для тіла, параметрів та запиту
Дізнайтеся, як перевіряти тіла запитів Express, параметри маршрутів та рядки запиту за допомогою одного багаторазово використовуваного проміжку Zod, а також як він доповнює перевірку моделей Sequelize.
Ніщо не заважає клієнту ввести число там, де ваш API очікує ім’я, або null там, де він очікує пароль. Код, який сліпо довіряється req.body, зрештою створює несправні записи або генерує помилки, які не відповідають їхній справжній причині. Цей посібник показує, як один раз описати допустимий вхідний формат за допомогою Zod, забезпечити його дотримання за допомогою єдиного middleware Express, який контролює тіло запиту, параметри маршруту та рядок запиту, а також зберегти фокус контролерів на бізнес-логіці.
Проблема: запити надходять без типізації
Ось цілком законний HTTP-пакет даних, який жоден ендпоїнт реєстрації не повинен приймати:
{
"fullName": 123,
"email": "hello",
"password": null
}
Кожне поле має неправильну структуру. Zod — це бібліотека схем для JavaScript та TypeScript, яка дозволяє точно визначити очікуваний формат та отримати або чисті дані, або структурований список проблем.
Опис вхідних даних у вигляді схеми
Припустимо, що для реєстрації потрібна рядок fullName, правильно сформована адреса електронної пошти email, password довжиною щонайменше вісім символів, а також необов’язкове ціле число age. У Zod це описується майже як самі вимоги:
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()
});
Правила знаходяться в одному об’єкті, а не у розкиданих операторах if. Правило щодо віку також вимагає мінімум 18 років: відсутній вік приймається, а вік 16 років — відхиляється.
Встановлення та імпорт
Zod є звичайною залежністю npm:
npm install zod
У CommonJS імпортуйте простір імен z за допомогою require:
const { z } = require('zod');
У ES modules використовуйте імпорт за іменем:
import { z } from 'zod';
Додавання зрозумілих повідомлень про помилки
Кожен валідатор приймає необов’язкове повідомлення, яке буде відображатися клієнтам:
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()
});
Навантаження, яке відповідає усім правилам, проходить без змін:
{
"fullName": "John Smith",
"email": "john@example.com",
"password": "password123",
"age": 25
}
У цьому випадку ім’я занадто коротке, адреса не містить домену, а пароль складається з трьох символів:
{
"fullName": "J",
"email": "invalid-email",
"password": "123"
}
Zod повідомляє про всі три проблеми одночасно, тож форма може підкреслити кожне недійсне поле за один запит.
Останні версії Zod (v4 та новіші) також пропонують валідатори верхнього рівня, такі як z.email(), та відмінюють використання ланцюгових конструкцій на кшталт z.string().email(). Ланцюгова форма все ще працює, але перевірте актуальну документацію для вашої версії.
Вибір між parse() та safeParse()
parse() викидає помилку
parse() повертає валідовані дані або викидає ZodError:
const data = registerSchema.parse(req.body);
У обробнику Express вам потрібно самостійно перехопити цю помилку або передати її за допомогою next(err).
safeParse() повертає результат
safeParse() ніколи не викликає винятків. Він повертає об’єкт із прапорцем success, що краще підходить для обробки запитів, оскільки недійсний вхідні дані є очікуваним результатом, а не винятком:
const result = registerSchema.safeParse(req.body);
У разі невдачі error.issues перераховує кожну проблему з її шляхом та повідомленням, готову до відповіді 400:
if (!result.success) {
return res.status(400).json({
success: false,
errors: result.error.issues
});
}
У разі успіху result.data містить оброблене значення:
const data = result.data;
Відтепер використовуйте result.data, а не req.body: невідомі ключі за замовчуванням видаляються, а також вже застосовані конвертації та значення за замовчуванням.
Найпростіша інтеграція викликає safeParse() всередині обробника:
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
});
});
Це працює, але при 20 чи 50 кінцевих точках одні й ті самі рядки вставляються у кожен контролер, і з часом вони починають відрізнятися. Також зверніть увагу, що цей приклад відправляє назад на клієнта підтверджений об’єкт, включаючи пароль; справжня кінцева точка має повертати лише несекретні поля.
Фабрика validate()
Наведена нижче фабрика бере схему та повертає обробник Express. Вона перевіряє тіло запиту, параметри та запит усе разом, у разі помилки повертає код 400, а в іншому випадку зберігає розпарсений результат у req.validated перед викликом 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;
Важливі два моменти. Запис у окрему властивість req.validated допомагає уникнути проблем у Express 5, де req.query є геттером та не може бути просто переатрибутований. Крім того, оскільки мідлвейр обгортає вхідні дані у формат { body, params, query }, схеми також мають відповідати цій структурі. Пряма функція registerSchema шукатиме поля fullName на верхньому рівні та відхилятиме кожен запит, тому її потрібно обгорнути у z.object({ body: registerSchema }) або змусити мідлвейр перевіряти лише req.body.
Підключення до маршруту
Мідлвейр розташовується між шляхом та контролером:
router.post(
'/register',
validate(registerSchema),
register
);
Пайплайн запитів стає таким:
Request
↓
Express Router
↓
Zod Validation Middleware
↓
Controller
↓
Service
↓
Database
Недійсні дані зупиняються на рівні мідлвейра, і контролер так і не запускається; дійсні дані продовжують обробку, причому гарантується, що вони відповідають схемі.
Тримання контролерів пов’язаними з логікою бізнесу
Без шару валідації контролер накопичує всі проблеми одночасно:
const register = async (req, res) => {
// validation
// check email
// validate password
// validate name
// business logic
// database operation
};
З використанням мідлверу він просто читає перевірені значення:
const register = async (req, res) => {
const {
fullName,
email,
password
} = req.validated.body;
// Business logic
};
Як бонус, схеми можна тестувати як одиничні тести зі звичайними об’єктами, і тестам контролерів більше не потрібен окремий випадок для кожного некоректного пакета даних.
Валідація параметрів маршруту з примусовою трансформацією
Той самий підхід застосовується до сегментів URL. Розглянемо запит на одного користувача:
GET /users/123
Схема для параметра id:
const userParamsSchema = z.object({
id: z.coerce.number().int().positive()
});
Приєднується як раніше (під ключем params при використанні вищезгаданого мідлверу):
router.get(
'/users/:id',
validate(userParamsSchema),
getUser
);
Ключовим елементом є примусова трансформація:
z.coerce.number()
Усе в URL — це текст. Значення
req.params.id
надходить у вигляді рядка
"123"
а не числа
123
Звичайна функція z.number() відхилятиме кожен запит. Функція z.coerce.number() спочатку обробляє вхідні дані за допомогою Number(), а потім застосовує методи .int() та .positive(). Є один крайній випадок: Number('') дорівнює 0, тож порожнє значення стає нулем. Тут метод .positive() це перешкоджає, але схема без нижньої межі дозволить йому пройти.
Перевірка рядків запиту з використанням значень за замовчуванням
Пагінація є класичним прикладом використання рядків запиту:
GET /users?page=1&limit=10
Застосування методів примусової трансформації разом із значеннями за замовчуванням забезпечує отримання безпечних чисел навіть у разі, якщо клієнт їх не вказує:
const userQuerySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(10)
});
Обмеження .max(100) також не дозволяє клієнту звернутися за мільйоном рядків одночасно.
Поширені елементи конструкції Zod
Більшість схем складаються з невеликої кількості елементів:
z.string(),z.number(),z.boolean()перевіряють примітивні типи.z.object()описує структуру об’єкта;z.array()перевіряє масив та його елементи.z.enum()обмежує значення фіксованим списком опцій..min()та.max()встановлюють межі для числового значення чи довжини рядка чи масиву..email()перевіряє формат електронної пошти;.int()вимагає цілого числа;.positive()— значення, більше нуля..optional()дозволяє відсутність поля;.nullable()дозволяє використання значенняnull;.default()заповнює відсутні значення.z.coerce— це простір імен, а не функція:z.coerce.number()та подібні функції конвертують вхідні дані перед їх перевіркою.
.refine() додає користувацькі правила; .transform() змінює форму значення після його обробки..parse() викидає помилку у разі невдачі; .safeParse() повертає результат успіху або помилки.Приклад: запис користувача з ролями
Користувач у додатку для керування дитячим садком може виглядати так:
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() відхиляє будь-які інші ролі, а isActive за замовчуванням має значення true, якщо його не вказано. Схема водночас є документацією.
Zod та Sequelize перевіряють різні рівні
Команди, які працюють з Sequelize та MySQL, часто запитують, навіщо їм Zod, адже у моделях вже є валідатори. Ці інструменти захищають різні межі.
Zod захищає межу API
Він перевіряє дані, які надходять через HTTP, перш ніж код додатку їх обробить:
HTTP Request
↓
Zod
↓
Controller
Sequelize захищає рівень даних
Їхні валідатори запускаються під час збереження моделі, глибоко у шарі сервісу:
Controller
↓
Service
↓
Sequelize
↓
MySQL
Використання обох
Разом вони утворюють два незалежні шари:
Client
↓
Express
↓
Zod
↓
Controller
↓
Service
↓
Sequelize
↓
MySQL
Zod забезпечує швидкі, зручні для клієнта відповіді 400; Sequelize виявляє помилки, що виникають всередині додатку, наприклад, під час виконання фонової задачі, яка створює некоректну запис. Базові обмеження бази даних, такі як NOT NULL та унікальні індекси, залишаються остаточним засобом безпеки.
Організація схем у більшому кодовому базисі
У проекті на основі модулів кожен модуль має файл валідації поруч із своїми маршрутами, контролером та сервісом, а спільний мідлвейр знаходиться у власній папці:
src/
├── modules/
│ └── users/
│ ├── user.controller.js
│ ├── user.service.js
│ ├── user.routes.js
│ └── user.validation.js
│
├── middleware/
│ └── validate.js
│
└── app.js
user.validation.js експортує схеми модуля:
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
};
а файл маршрутів залишається коротким:
router.post(
'/users',
validate(createUserSchema),
createUser
);
Коли змінюється поле, контролер та його правила редагуються разом. Щоб повторно використати ті самі схеми у браузері, дивіться спосіб спільного використання однієї схеми Zod у React та Node.
Чому єдине джерело істини є корисним
Без схеми перевірка даних потрапляє до контролерів у вигляді специфічних перевірок:
if (!email) {
// ...
}
if (!password) {
// ...
}
if (password.length < 8) {
// ...
}
if (!['admin', 'teacher'].includes(role)) {
// ...
}
Кожен кінцевий пункт повторює трохи іншу версію, і ніхто не може одразу побачити повний контракт. Відповідна схема описує це кількома рядками:
const userSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['admin', 'teacher'])
});
Це угода між API та клієнтами, яка забезпечується в одному місці. Для порівняння з іншим популярним підходом дивіться Zod проти express-validator.
Основні висновки
Справжня цінність полягає у порядку виконання обов’язків, який забезпечує Zod:
Request
↓
Validation
↓
Controller
↓
Business Logic
↓
Database
- Перевіряйте дані на вході за допомогою
safeParse(), щоб лишеresult.dataпотрапляло до обробників. - Централізуйте перевірку в одному мідлвейрі та забезпечте, щоб кожна схема відповідала формату даних, які вона обробляє.
- Використовуйте
z.coerceдля параметрів та рядків запиту, а також встановлюйте межі для значень, наприклад, розміру сторінки. - Залишайте валідатори ORM та обмеження бази даних як другий рівень захисту, а не їх заміну.
- Розміщуйте схеми разом із відповідними модулями, щоб зміни у контракті супроводжувалися змінами в коді.
Пов’язана література
- Zod проти express-validator: два підходи до верифікації даних у Express — порівнює верифікацію запитів на основі схеми за допомогою Zod з використанням проміжників express-validator, що ґрунтуються на ланцюгах, розглядаючи налаштування, форматування помилок та поширені проблеми.
- Спільне використання однієї схеми Zod у вашому фронтенді на React та бекенді на Node — дізнайтеся, як одна схема Zod може верифікувати форми в React, відповіді API, тіла запитів у Express та змінні середовища, водночас генеруючи відповідні типи TypeScript.