Зарегистрируйтесь, чтобы продолжить обучение

Валидация бизнес-правил JS: REST API (Fastify)

Встроенная валидация в 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 состоит из трех этапов:

  1. Описание схемы данных включая правила валидации и нормализации. Помимо стандартных правил для подобных библиотек, VineJS содержит множество правил специфичных для HTTP. Например, в примере выше, метод confirm() проверяет, что передано поле password_confirmation со значением совпадающим с исходным полем password.
  2. Компиляция схемы для ускорения работы. Выполняется ровно один раз на всем протяжении жизни приложения.
  3. Проверка и преобразование данных. Дальше по коду используется объект, вернувшийся после вызова метода validate(), так как внутри могут происходить преобразования типов и нормализация.

Интеграция VineJS в проект

Мы уже используем встроенную валидацию в Fastify, поэтому часть связанную с проверкой обязательности и преобразования типов в VineJS можно пропустить. По умолчанию VineJS пропускает и возвращает только те поля, которые указаны в схеме. Это поведение меняется вызовом метода allowUnknownProperties():

const schema = vine.object({
  // валидации
}).allowUnknownProperties()

Благодаря такому подходу, нам останется описать только те правила, которые невозможно выразить в JSON Schema.

Подключение

  1. Создайте директорию validators в корне проекта.
  2. Добавьте в нее файл 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 уровня проверки данных:

  1. Проверка данных по OpenAPI спецификации самим Fastify.
  2. Проверка бизнес-правил через VineJS.
  3. Ограничения в базе данных

Проверка уникальности

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


Самостоятельная работа

  1. Добавьте проверку уникальности емейл при создании и редактировании пользователя
  2. Запушьте изменения в репозиторий

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff