Встроенная валидация в Fastify отвечает за проверку структуры входных и выходных данных, но это только часть воронки валидации. Правильная структура не гарантирует, что данные пройдут следующий этап - проверку бизнес-требований.
Классический пример это валидация email. Если такой адрес уже есть, то мы не можем зарегистрировать нового пользователя с тем же адресом. Сюда же можно отнести требования к паролю, указание связанных данных (через идентификаторы) и другие правила. Часто они завязаны на согласованность данных в базе.
Подобную валидацию можно реализовать двумя способами:
- Самостоятельно делая выборки из базы данных, выполняя проверки и генерируя ответ.
- Используя готовую библиотеку.
Fastify не предоставляет для этого никаких встроенных средств, поэтому нужна готовая библиотека. Наиболее подходящей библиотекой для этой задачи будет VineJS.
VineJS
VineJS — это валидатор данных, ориентированный на обработку входных данных, поступающих по HTTP-запросам, таких как формы или JSON, с акцентом на серверные приложения. В отличие от библиотек типа Yup или Zod, которые чаще применяются для клиентской валидации, VineJS предоставляет более мощные возможности для асинхронных проверок и интеграции с базами данных или внешними API. Кроме валидации, VineJS поддерживает нормализацию данных. Например, можно автоматически привести строки к нижнему регистру, чтобы обеспечить единообразие данных.
Установка библиотеки
npm i @vinejs/vine
Базовый пример использования
import vine from '@vinejs/vine'
// Описываем схему данных
const schema = vine.object({
email: vine.string()
.email()
.normalizeEmail({
all_lowercase: true,
}),
password: vine
.string()
.minLength(8)
.maxLength(32)
.confirmed(),
})
// Генерируем валидатор на основе схемы
// Выполняется ровно один раз
const validator = vine.compile(schema)
const data = {
email: 'Support@HEXLET.io',
password: 'mysecret',
password_confirmation: 'mysecret',
}
// Валидируем данные на основе схемы
const validated = await validator.validate(data)
console.log(validated)
// {
// email: 'support@hexlet.io',
// password: 'mysecret',
// password_confirmation: 'mysecret'
// }
Работа Vine состоит из трех этапов:
- Описание схемы данных включая правила валидации и нормализации. Помимо стандартных правил для подобных библиотек, VineJS содержит множество правил специфичных для HTTP. Например, в примере выше, метод
confirm()
проверяет, что передано полеpassword_confirmation
со значением совпадающим с исходным полемpassword
. - Компиляция схемы для ускорения работы. Выполняется ровно один раз на всем протяжении жизни приложения.
- Проверка и преобразование данных. Дальше по коду используется объект, вернувшийся после вызова метода
validate()
, так как внутри могут происходить преобразования типов и нормализация.
Интеграция VineJS в проект
Мы уже используем встроенную валидацию в Fastify, поэтому часть связанную с проверкой обязательности и преобразования типов в VineJS можно пропустить. По умолчанию VineJS пропускает и возвращает только те поля, которые указаны в схеме. Это поведение меняется вызовом метода allowUnknownProperties()
:
const schema = vine.object({
// валидации
}).allowUnknownProperties()
Благодаря такому подходу, нам останется описать только те правила, которые невозможно выразить в JSON Schema.
Подключение
- Создайте директорию validators в корне проекта.
Добавьте в нее файл UserValidator.js с таким содержимым:
import vine from '@vinejs/vine' import { users } from '../db/schema.js' const schema = vine.object({ email: vine.string() .email() .normalizeEmail({ all_lowercase: true }), }).allowUnknownProperties() const validator = vine.compile(schema) class UserValidator { static validate(data) { return validator.validate(data) } } export default UserValidator
Вариант с классом опциональный. Существует множество разных способов, как можно описать валидаторы в приложении. Главное правило - схема должна компилироваться один раз.
Использование
fastify.post(
'/users',
{
onRequest: [fastify.authenticate],
schema: {
body: schema['/users'].POST.args.properties.body,
response: {
201: schema['/users'].POST.data,
422: schema['/users'].POST.error,
},
},
},
async (request, reply) => {
// Проверка данных
const validated = await UserValidator.validate(request.body)
const [user] = await db.insert(schemas.users)
.values(validated)
.returning()
return reply.code(201)
.send(user)
},
)
На текущий момент мы получили 3 уровня проверки данных:
- Проверка данных по OpenAPI спецификации самим Fastify.
- Проверка бизнес-правил через VineJS.
- Ограничения в базе данных
Проверка уникальности
В некоторых ситуациях стандартных проверок VineJS недостаточно, например, для проверки уникальности. Эта проверка завязана на работу с базой данных, поэтому ее придется написать самостоятельно. Для этого в VineJS есть механизм создания и использования кастомных правил. Для Drizzle файл с подобным правилом может быть реализован так:
// Можно хранить в rules/unique.js
import { drizzle } from 'drizzle-orm/better-sqlite3'
import vine from '@vinejs/vine'
import * as schemas from '../db/schema.js'
import { eq } from 'drizzle-orm'
async function unique(value, options, field) {
if (typeof value !== 'string') {
return
}
// Схема Drizzle и db передаются снаружи
// Ниже будет пример
const db = field.meta.db
// Проверяем наличие строк в базе данных с таким значением для field.name
const [row] = await db.select().from(options.schema)
.where(eq(options.schema[field.name], value))
if (row) {
field.report(
`The {{ field }} field (= ${value}) is not unique.`,
'unique',
field,
)
}
}
export default vine.createRule(unique, {
// implicit: true,
isAsync: true,
})
Большая часть описания правила взята из офциальной документации VineJS. Его основной код находится там, где идет работа с базой данных:
const [row] = await db.select().from(options.schema)
.where(eq(options.schema[field.name], value))
Запрос проверяет наличие в базе записей с field.name
равным value
. Теперь посмотрим на использование этого правила:
import uniqueRule from '../rules/unique.js'
const schema = vine.object({
email: vine.string()
.email()
.normalizeEmail({ all_lowercase: true })
.use(uniqueRule({ schema: users })),
})
Параметры метода uniqueRule
попадают в наш правило в виде объекта options
. Схему логично передавать именно тут, потому что в проекте она определяется статично, в отличие от соединения с базой данных, которое создается во время работы приложения. Поэтому UserValidator
принимает такой вид:
class UserValidator {
static validate(data, db) {
// Динамические данные попадают во внутрь как meta
return validator.validate(data, { meta: { db } })
}
}
Внутри правила метаданные доступны в объекте field.meta
.
Обработка ошибок
Когда валидация заканчивается с ошибкой, то выбрасывается исключение, которое Fastify никак не обрабатывает. Поэтому, по умолчанию, Fastify вернет 500 без объяснения, что произошло. Это поведение нужно менять, причем делать это глобально для всего приложения. Для этого в файл app.js нужно добавить такой обработчик:
import { errors } from '@vinejs/vine'
fastify.setErrorHandler(function (error, _request, reply) {
if (error instanceof errors.E_VALIDATION_ERROR) {
const errorDetail = {
status: 422,
title: 'Validation Error',
detail: 'Errors related to business logic such as uniqueness',
errors: error.messages,
}
reply.code(422).send(errorDetail)
}
else {
reply.send(error)
}
})
Здесь мы проверяем тип ошибки и если это ошибка VineJS, то мы формируем объект с описанием ошибок и кодом 422. Почему именно такая структура? В HTTP существует стандарт Problem Details for HTTP APIs (RFC 9457), который описывает как нужно возвращать ошибки. С ним мы разберемся в следующем уроке.
Самостоятельная работа
- Добавьте проверку уникальности емейл при создании и редактировании пользователя
- Запушьте изменения в репозиторий
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.