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

Валидация данных запроса и ответа JS: REST API (Fastify)

OpenAPI спецификация содержит описание структур данных в свойстве components в виде JSON Schema. Она описывает структуру входных и выходных данных включая: имена полей и их обязательность, типы полей и правила валидации.

components:
  schemas:
    User:
      type: object
      required:
        - id
        - fullName
        - email
        - createdAt
      properties:
        id:
          type: number
        fullName:
          type: string
          nullable: true
          minLength: 2
          maxLength: 100
        email:
          type: string
          format: email
        createdAt:
          type: string
          format: date-time

JSON Schema позволяет реализовать несколько полезных вещей:

  • Получить готовые структуры, например классы и интерфейсы в языках где они поддерживаются, например в TypeScript. Это дает поддержку редактора для автокомплита и подсветки ошибок.
  • Автоматизировать процесс проверки входных и выходных данных в рантайме, с возвратом нужного кода ответа.

Валидация в Fastify

Fastify поддерживает валидацию на базе JSON Schema из коробки. Поддерживаемые валидации включают:

  • body: Тело запроса.
  • querystring: Строка запроса.
  • params: Параметры запроса, например /users/:id (id параметр).
  • headers: Заголовки запроса.
  • response: Тело ответа с учетом разных кодов и форматов.

Пример:

const bodyJsonSchema = {
  type: 'object',
  required: ['requiredKey'],
  properties: {
    someKey: { type: 'string' },
    someOtherKey: { type: 'number' },
    requiredKey: {
      type: 'array',
      maxItems: 3,
      items: { type: 'integer' },
    },
  },
}

const queryStringJsonSchema = {
  type: 'object',
  properties: {
    page: { type: 'integer' },
    direction: { type: 'string' },
  },
}

const paramsJsonSchema = {
  type: 'object',
  properties: {
    id: { type: 'integer' },
  },
}

const headersJsonSchema = {
  type: 'object',
  properties: {
    'x-foo': { type: 'string' },
  },
  required: ['x-foo'],
}

const responseJsonSchema = {
  200: {
    type: 'object',
    properties: {
      value: { type: 'string' },
      otherValue: { type: 'boolean' },
    },
  },
}

const schema = {
  body: bodyJsonSchema,
  querystring: queryStringJsonSchema,
  params: paramsJsonSchema,
  headers: headersJsonSchema,
  response: responseJsonSchema,
}

fastify.post('/users', { schema }, handler)

Fastify не только сверяет совпадение по структуре и типами, но так же выполняет преобразования типов. По умолчанию все входящие данные являются строкой, но если, например, в схеме указано, что параметр page это число, то значение этого параметра будет преобразовано в число. Ручных преобразований при таком подходе не будет вообще.

fastify.get(
  '/users',
  {
    schema: {
      querystring: {
        type: 'object',
        properties: {
          page: { type: 'integer' },
        },
      },
    },
  },
  async function (request) {
    // page преобразуется в number автоматически
    const { page = 1 } = request.query
    const users = await db.query
      .users
      .findMany({
        orderBy: asc(schemas.users.id),
        ...getPagingOptions(page, 1),
      })

    return users
  },
)

Когда структуры не совпадают, то Fastify автоматически обрабатывает ошибки, возвращая код 400.

npm run dev

> js-fastify-rest-api-example@1.0.0 dev
> fastify start -w -l info -P app.js

[12:53:46.390] INFO (29532): Server listening at http://[::1]:3000
[12:53:46.390] INFO (29532): Server listening at http://127.0.0.1:3000

curl localhost:3000/api/users
[{"id":1,"fullName":"Ms. Carol Boyle","email":"Kenton_Funk@yahoo.com","updatedAt":null,"createdAt":"1726592026"}]

curl "localhost:3000/api/users?page=str"
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"querystring/page Expected number"}

Валидация на базе OpenAPI

Благодаря подходу API First, у нас уже есть все необходимые схемы в файле openapi.json, сгенерированным TypeSpec. В теории мы могли бы прочитать этот файл внутри Fastify и передать схемы в каждый обработчик. На практике, это будет неудобно из-за указания на связи $ref, которые автоматически не раскрываются. Придется много делать много дополнительных движений.

paths:
  /users:
    get:
      operationId: users_index
      parameters:
        - name: page
          in: query
          required: false
          schema:
            type: number
            default: 1
          explode: false
      responses:
        '200':
          description: The request has succeeded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/User'
                required:
                  - data

Но есть и более серьезная проблема. При использовании TypeScript, передача схем напрямую не формирует нужные типы. Это значит, что работая внутри обработчика мы постоянно будем натыкаться на ошибки при обращении к полям входных данных о том, что они не существуют. Эта проблема актуальна и для JavaScript.

TypeBox

Чтобы добиться нужного эффекта с преобразованием схемы в типы, нужно добавить в процесс новый элемент, который называется TypeBox. Эта библиотека представляет собой язык описания JSON Schema на JavaScript/TypeScript.

import { Type } from '@sinclair/typebox'

const T = Type.Object({ // const T = {
  x: Type.Number(), //   type: 'object',
  y: Type.Number(), //   required: ['x', 'y', 'z'],
  z: Type.Number(), //   properties: {
}) //     x: { type: 'number' },
//     y: { type: 'number' },
//     z: { type: 'number' }
//   }
// }

Но работать с ней напрямую нам не нужно. Процесс будет выглядеть так:

  • Указываем Fastify, что мы работаем не с JSON Schema, а TypeBox.
  • С помощью библиотеки генерируем TypeBox схему на основе OpenAPI спецификации.
  • Подключаем TypeBox схему к обработчикам, вместо прямого описания JSON Schema

Установка и настройка

  1. Установим пакеты для совместной работы Fastify и Typebox

    npm i @fastify/type-provider-typebox @sinclair/typebox
    
  2. Укажем Fastify на необходимость использовать TypeBox. Внутри файла app.js поменяем объект fastify на объект api и заменим использование одного на другой

    import { TypeBoxValidatorCompiler } from '@fastify/type-provider-typebox'
    
    export default fp(async function (fastify, opts) {
      const api = fastify
        .setValidatorCompiler(TypeBoxValidatorCompiler)
        .withTypeProvider()
    
      // В коде ниже нужно заменить использование fastify на api
    }
    

Генерация схемы

Схема генерируется командой openapi-box:

npx openapi-box ./tsp-output/@typespec/openapi3/openapi.json

Результирующий файл называется schema.js и располагается в корне проекта. Внутри него описано множество подобных определений:

const ComponentsSchemasUser = T.Object({
  id: T.Number(),
  fullName: T.Union([T.Null(), T.String()]),
  email: T.String(),
  createdAt: T.String({ format: 'date-time' }),
})

Для удобства имеет смысл объединить команды компиляции tsp и генерации схемы openapi-box в одну команду, например с помощью Makefile:

types-to-openapi:
	npx tsp compile .

types-to-typebox:
	npx openapi-box ./tsp-output/@typespec/openapi3/openapi.json

types: types-to-openapi types-to-typebox

В таком случае для генерации всех нужных файлов будет достаточно набрать make types:

make types

npx tsp compile .
TypeSpec compiler v0.59.1

Compilation completed successfully.

npx openapi-box ./tsp-output/@typespec/openapi3/openapi.json
✔ Schema done!

Использование схемы

Для подключения готовой схемы, ее нужно импортировать в нужный файл с маршрутами и добавить в обработчики.

import { schema } from '../../schema.js'

/**
  * @param {import('fastify').FastifyTypebox} fastify
  */
export default async function (fastify) {
  fastify.get(
    '/users',
    {
      schema: {
        querystring: schema['/users'].GET.args.properties.query,
      },
    },
    async (request) => {
      // Код
    },
  )

  fastify.get(
    '/users/:id',
    {
      schema: schema['/users/{id}'].GET.args.properties,
    },
    async (request) => {
      // Код
    },
  )

  fastify.post(
    '/users',
    {
      schema: {
        body: schema['/users'].POST.args.properties.body,
        response: {
          201: schema['/users'].POST.data,
        },
      },
    },
    async (request, reply) => {
      // Код
    },
  )

  fastify.patch(
    '/users/:id',
    {
      // Возвращает { params: ..., body: ... }
      schema: schema['/users/{id}'].PATCH.args.properties,
    },
    async (request) => {
      // Код
    },
  )

  fastify.delete(
    '/users/:id',
    {
      // Возвращает { params: ... }
      schema: schema['/users/{id}'].DELETE.args.properties,
    },
    async (request, reply) => {
      // Код
    },
  )
}

Импортируемый объект из файла schema.js имеет следующую структуру:

# Для возвращаемых данных
<маршрут>.<МЕТОД>.data

# Для входных данных
<маршрут>.<МЕТОД>.args.properties.<query|body|params>

К этой схеме нужно привыкнуть. Запоминать ее не нужно, проще всего она отрабатывается на практических примерах, в идеале через подсказки редактора, как это происходит с нашим примером на Github.


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

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

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

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

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

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

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

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

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

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