Перевірка вхідних даних з боку сервера в Express за допомогою express-validator Chains
Дізнайтеся, чому API Express мають перевіряти вхідні дані на сервері, як ланцюги express-validator записують помилки для validationResult та як структурувати маршрути для продакшну.
Джерела: Stop Trusting User Input: A Beginner’s Guide to `express-validator` in Express.js
Рано чи пізно кожен API Express, який приймає вхідні дані, отримує щось на кшталт "age": "banana", і сервер або зупиняється, або, що гірше, зберігає ці дані. Валідація — це механізм, який запобігає цьому, і її належна реалізація на бекенді має велике значення, незалежно від того, наскільки ретельними є форми фронтенду. У цьому посібнику пояснюється, що таке валідація та чому відповідальність за неї лежить на сервері, показаний ручний підхід та моменти, коли він призводить до проблем, а також розглядається внутрішня робота express-validator, які методи використовуються найчастіше та як реалістичний маршрут поєднує валідацію, очищення даних та перевірки з базою даних.
Що означає валідація
Валідація — це процес підтвердження того, що надходящі дані мають потрібну форму та зміст, перш ніж ваш код почне з ними працювати. Корисною аналогією є перевірка документів біля входу: не має значення, наскільки впевнено хтось виглядає, працівники перевіряють його посвідчення, і якщо воно не підтверджує особу, людина не може увійти. Валідація застосовує таку саму ретельну перевірку до кожного поля запиту. Чи справді це адреса електронної пошти? Чи справді це число? Чи взагалі є це поле?
Розгляньмо ендпоїнт реєстрації. Добре сформоване тіло запиту може виглядати так:
{
"name": "Rahul Sharma",
"email": "rahul@example.com",
"age": 25,
"password": "supersecret123"
}
Його валідація означає підтвердження того, що email сформований у вигляді адреси електронної пошти, що age — це число, а не текст, та що password має мінімальну довжину, яка робить його більш ніж формальністю.
Чому бекенд не може покладатися на перевірки фронтенду
Поширеною запереченням є те, що форма реєстрації вже позначає поля як required та перевіряє формат електронної пошти в браузері. Цього недостатньо, адже з точки зору зловмисника фронтенд є необов’язковим. Будь-хто може безпосередньо викликати ваш API за допомогою Postman, curl чи скрипту та обійти всі правила вашого інтерфейсу. Якщо сервер не перевіряє дані сам, він фактично довіряє тому, хто надсилає запит.
Конкретні переваги верифікації з боку сервера:
- Це захищає ваші дані. Коли стовпець очікує число, а отримує
"twenty-five", виникає помилка або, що гірше, пошкоджений запис, який спричиняє проблеми значно пізніше.
Розподіл обов’язків простий: верхній рівень валідації існує для допомоги користувачам, а нижній рівень — для захисту додатку. Обидва є корисними, але лише нижній рівень є обов’язковим.
Ручна валідація та причини, чому вона заважає масштабуванню
Варто спочатку подивитися ручну версію. Ось початок маршруту реєстрації, який отримує поля з обробленого тіла (це передбачає наявність парсера JSON-тіла, наприклад express.json()):
app.post("/register", (req, res) => {
const { name, email, age, password } = req.body;
Кожне поле потім отримує власну клозу-захист, яка повертає відповідь 400:
if (!name || typeof name !== "string" || name.trim().length === 0) {
return res.status(400).json({ error: "Name is required" });
} if (!email || !email.includes("@")) {
return res.status(400).json({ error: "A valid email is required" });
} if (!age || typeof age !== "number" || age < 18) {
return res.status(400).json({ error: "Age must be a number and at least 18" });
} if (!password || password.length < 8) {
return res.status(400).json({ error: "Password must be at least 8 characters" });
} // If we made it here, the data is safe to use
res.json({ message: "User registered successfully" });
});
Для невеликого додатку це цілком прийнятно. Однак у міру зростання кодової бази проблеми накопичуються:
- Кожен маршрут, який приймає вхідні дані, потребує власного блоку операторів
if. - Перевірки копіюються між маршрутами, і виправлення, застосоване до однієї копії, легко може бути пропущене в інших.
null, неправильні типи та значення, які занадто короткі чи занадто довгі, швидко стає клопітким.У ручній версії також є непомітні прогалини. email.includes("@") приймає багато недійсних адрес, а виклик .includes для значення, яке не є рядком, спричиняє помилку. Така повторювана та схильна до помилок перевірка — саме те, що має обробляти бібліотека.
Що надає express-validator
express-validator — це набір середовищ Express, створений на основі бібліотеки validator.js. Замість того, щоб писати умовну логіку для кожного поля, ви оголошуєте правила, а бібліотека перевіряє дані запиту за ними. Почніть з імпорту двох основних функцій:
const { body, validationResult } = require("express-validator");
Та сама маршрутна реєстрація, переписана за допомогою декларативних правил, виглядає так:
app.post(
"/register",
[
body("name").trim().notEmpty().withMessage("Name is required"),
body("email").isEmail().withMessage("A valid email is required"),
body("age").isInt({ min: 18 }).withMessage("Age must be at least 18"),
body("password").isLength({ min: 8 }).withMessage("Password must be at least 8 characters"),
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
} res.json({ message: "User registered successfully" });
}
);
Тепер правила сформульовані майже простою мовою: ім’я не може бути порожнім після обробки, електронна пошта має відповідати формату електронної пошти, вік має бути цілим числом не менше 18, а пароль — містити щонайменше вісім символів. Функція обробки в кінці розглядає лише одне питання: чи щось зазнало невдачі?
Що відбувається, коли надходить запит
Найпоширеніше непорозуміння серед початківців — очікування того, що express-validator автоматично відхилятиме некоректні запити. Це не так. Він збирає інформацію про проблему та залишає формування відповіді повністю на ваш розсуд. Послідовність дій для кожного запиту є такою:
- Express запускає ланцюг мідлверів, визначений для маршруту, та виконує кожен елемент у порядку.
body("email").isEmail(), він читає значення email з req.body та перевіряє його. У разі невдачі не виникає помилки та запит не завершується; ланцюг записує помилку у об’єкт запиту та продовжує роботу.validationResult(req) читає записані помилки. Це звичайна функція, а не мідлвейр, тому її викликають усередині обробника запиту. Вона сама нічого не перевіряє; вона лише повідомляє про те, що вже було зібрано ланцюгами.errors.isEmpty() повертає false, ви надсилаєте повідомлення 400 із деталями. Інакше ви переходите до основної роботи.Дизайн віддає перевагу гнучкості перед зручністю. Оскільки відхилення не відбувається автоматично, ви контролюєте формат та час відповіді, а також можете виконувати іншу логіку між перевірками та остаточним рішенням. Недолік полягає у тому, що забування виклику validationResult() беззвучно вимикає перевірку для цього маршруту, тому багато команд обгортають цю перевірку у невеликий багатокористувацький мідлвейр.
Чотири основні елементи
Як тільки ви знаєте ці елементи, бібліотека стає простою у використанні.
Ланцюги перевірки
Функції на кшталт body(), query(), param() та check() створюють ланцюг — послідовність правил, з’єднаних крапками, наприклад body("email").trim().isEmail(). Кожен з них орієнтований на різну частину запиту:
body()читаєreq.body— JSON-дані або вміст форми.query()читаєreq.query— рядок запиту після символа?, як у?page=2.param()читаєreq.params— параметри маршруту, такі як:idу/users/:id.check()одночасно перевіряє всі частини запиту.
Валідатори
Валідатори — це функції, які перевіряють значення: isEmail(), isInt(), isLength(), isAlphanumeric() та багато інших. Більшість з них делегують обов’язки validator.js, який вже містить досконалу, ретельно протестовану логіку для поширених форматів.
Санітизатори
Санітизатори перетворюють дані, а не просто їх перевіряють. trim() видаляє зайві пробіли, normalizeEmail() перетворює адресу у стандартну форму, а escape() замінює символи, значущі для HTML, на їхні еквіваленти-ентитети, щоб їх не можна було інтерпретувати як маркування. Санітизатори можна поєднувати з валідаторами в одному ланцюжку, причому порядок має значення: trim(), розміщений перед notEmpty(), означає, що значення, яке складається лише з пробілів, буде відхилене.
База даних результатів за запитом
Кожного разу, коли виконується ланцюг обробки запитів, його помилки приєднуються до конкретного об’єкта req. Функція validationResult(req) просто зчитує інформацію з цього сховища, тому їй потрібен той самий об’єкт запиту, який обробляли ланцюги.
У сукупності процес виглядає так: надходить запит, ланцюги виконуються та тихо фіксують проблеми, обробник запитуває результати, а ви обираєте відповідь. Якщо ви порівнюєте цей підхід із верифікацією на основі схем, наша порівняльна стаття Zod та express-validator розглядає переваги та недоліки кожного з них.
Методи, які підходять для більшості реальних проектів
Вам не потрібен весь API — невелика кількість методів достатня для обробки більшості випадків.
Селектори місця розташування даних
body(field)— для полів у тілі запитуquery(field)— для параметрів у рядку запиту
param(field) для параметрів маршрутуПоширені валідатори
.notEmpty(): значення не може бути порожнім.isEmail(): значення має бути коректною адресою електронної пошти.isInt({ min, max }): значення має бути цілим числом, за бажанням у межах певних значень.isLength({ min, max }): перевіряє довжину рядка.isString(): значення має бути рядком.isBoolean(): значення має бути логічним значенням.matches(regex): значення має відповідати власному шаблону.isIn([...values]): значення має бути одним із елементів фіксованого набору, що зручно для полів на кшталтstatus, які можуть бути або"active", або"inactive"
Поширені санітайзери
.trim(): видаляє пробіли з початку та кінця.escape(): ескейпує символи HTML для зниження ризику втручання.normalizeEmail(): стандартизує формат електронної пошти.toInt()та.toFloat(): перетворюють рядковий вхід на числа
Власні правила
.custom((value, { req }) => { ... }): дозволяє реалізувати власне правило, коли жоден з вбудованих верифікаторів не підходить, наприклад для перевірки того, чи не зареєстрований вже ім’я користувача
Звітування про помилки
.withMessage("text"): встановлює повідомлення для попереднього правилаvalidationResult(req): збирає всі помилки для поточного запитуerrors.array(): перетворює ці помилки на звичайний масив, придатний для JSON-відповіді
Шлях у стилі продакшну
Наведений нижче приклад більше нагадує справжню API: шлях для створення запису у блогу, який поєднує перевірку даних, їх очищення та запит до бази даних. Спочатку йдуть імпорти, де Post позначає модель, наприклад схему Mongoose:
const express = require("express");
const { body, validationResult } = require("express-validator");
const Post = require("./models/Post"); // a Mongoose model, for example
Потім — роутер та сам шлях із масивом правил:
const router = express.Router();router.post(
"/posts",
[
body("title")
.trim()
.notEmpty().withMessage("Title is required")
.isLength({ max: 100 }).withMessage("Title cannot exceed 100 characters"), body("slug")
.trim()
.notEmpty().withMessage("Slug is required")
.matches(/^[a-z0-9-]+$/).withMessage("Slug can only contain lowercase letters, numbers, and hyphens")
.custom(async (slug) => {
const existing = await Post.findOne({ slug });
if (existing) {
throw new Error("This slug is already taken");
}
return true;
}), body("content")
.trim()
.notEmpty().withMessage("Content cannot be empty")
.isLength({ min: 20 }).withMessage("Content should be at least 20 characters"), body("tags")
.optional()
.isArray().withMessage("Tags must be an array")
.custom((tags) => tags.every((t) => typeof t === "string"))
.withMessage("Each tag must be a string"),
],
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
} try {
const post = await Post.create(req.body);
res.status(201).json(post);
} catch (err) {
res.status(500).json({ error: "Something went wrong while saving the post" });
}
}
);module.exports = router;
Є кілька деталей, які варто розглянути:
- Правила, засновані на базі даних. Ланцюжок
slugвикористовує асинхронний валідатор.custom()для пошуку наявних записів. Регулярні вирази можуть підтвердити формат slug, але лише запит до бази даних може підтвердити його унікальність. Виклик цієї функції позначає поле як недійсне з відповідним повідомленням.
tags позначене як .optional(), тож повідомлення без тегів проходить перевірку, але якщо теги вказані, вони мають бути масивом рядків.Два застереження перед копіюванням цього шаблону. По-перше, перевірка унікальності в валідаторі піддається ризику конкуренції: два запити з однаковим ідентифікатором можуть обидва пройти перевірку перед тим, як буде здійснено збереження, тому необхідно підтримувати унікальний індекс у стовпці та також обробляти помилку дублікату ключа. По-друге, передача req.body безпосередньо до Post.create() зберігає всі додаткові поля, які надіслав клієнт, а не лише ті, що пройшли валідацію. express-validator надає функцію matchedData(req), яка повертає лише ті поля, що покриваються вашими правилами валідації, і її використання усуває цю проблему масового присвоєння даних.
У міру зростання застосунку слід переміщувати масиви правил у окремі модулі, наприклад validators/postValidator.js, та імпортувати їх у файли маршрутів, щоб маршрути не перетворювалися на суцільні блоки коду валідації.
Підсумок
Перевірка даних — це не необов’язковий елемент; вона визначає, чи може ваше додаток довіряти даним, які він обробляє. Ручні перевірки за допомогою if підходять для кількох полів, але швидко стають неефективними при роботі з великою кількістю маршрутів.
express-validator — це не чари. Він виконує оголошені правила як мідлвейр, фіксує помилки у запиті та дозволяє функції validationResult() надати вам звіт, щоб ви могли вирішити, яка буде відповідь. З огляду на цей підхід, решта бібліотеки — це набір інструментів для перевірки та очищення даних, які можна використовувати за потреби. Найважливіші моменти на практиці:
- Вважайте перевірку на боці сервера обов’язковою, незалежно від перевірок на боці клієнта.
- Завжди викликайте
validationResult(), бажано з одного спільного мідлвейра, адже бібліотека ніколи не відхиляє запит самостійно. - Усвідомлено визначайте порядок виконання функцій очищення даних у ланцюзі.
matchedData(req), а не сирий вміст запиту.