Главная / Статьи / Охрана границы экспресс-запроса: один middleware Zod для тела запроса, параметров и строки запроса

Охрана границы экспресс-запроса: один middleware Zod для тела запроса, параметров и строки запроса

Узнайте, как проверять тело запросов Express, параметры маршрутов и строки запросов с помощью одного повторно используемого промежуточного компонента Zod, а также как он дополняет проверку моделей Sequelize.

1982 слов

Ничто не мешает клиенту отправить число там, где ваш 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 и ограничения базы данных в качестве второго уровня защиты, а не замены им.
    • Размещайте схемы рядом со своими модулями, чтобы изменения в контракте сопровождались соответствующими изменениями в коде.

    Связанная литература

  • Identify vs Shape: Выбор параметров маршрута или строк запроса в Express — Узнайте, когда значение следует использовать в параметре маршрута Express или в строке запроса, как читать req.params и req.query, а также как безопасно обрабатывать значения по умолчанию и различные типы данных.
  • Метод HTTP QUERY для команд фронтенда: безопасное чтение с телом запроса — Узнайте, когда метод HTTP QUERY превосходит GET и POST для сложных фильтров, как использовать его с помощью fetch, и какие требования предъявляют CORS, кэширование и инфраструктурная поддержка.