API валидация

Joi — библиотека для декларативной валидации данных в приложениях на JavaScript и Node.js. Основная задача Joi — проверка структуры, типов и ограничений входящих данных до начала бизнес-логики.

В контексте API Joi применяется для:

  • валидации body HTTP-запросов;
  • проверки query-параметров;
  • проверки route params;
  • нормализации входных данных;
  • преобразования типов;
  • формирования понятных сообщений об ошибках;
  • защиты от некорректных или вредоносных данных.

Типичный сценарий:

POST /users

API получает JSON:

{
  "name": "Alex",
  "email": "alex@mail.com",
  "age": 25
}

Перед сохранением данных сервер обязан проверить:

  • наличие обязательных полей;
  • типы значений;
  • допустимые диапазоны;
  • формат email;
  • ограничения длины строк.

Joi позволяет описать эти правила в виде схем.


Установка

Установка через npm

npm install joi

Подключение

const Joi = require('joi');

Для ES-модулей:

import Joi fr om 'joi';

Базовая схема валидации

Простая проверка объекта

const schema = Joi.object({
  name: Joi.string().required(),
  age: Joi.number().required()
});

const data = {
  name: 'John',
  age: 30
};

const result = schema.validate(data);

console.log(result);

Результат:

{
  value: { name: 'John', age: 30 }
}

При ошибке:

{
  value: { name: 'John' },
  error: ...
}

Метод validate

Сигнатура

schema.validate(value, options)

Возвращаемый объект

{
  value,
  error,
  warning
}

Пример

const schema = Joi.string().min(5);

const result = schema.validate('abc');

console.log(result.error.message);

Вывод:

"value" length must be at least 5 characters long

Типы данных

String

Joi.string()

Методы строк

Joi.string().min(3).max(30)
Joi.string().email()
Joi.string().pattern(/^[A-Z]+$/)
Joi.string().alphanum()
Joi.string().token()
Joi.string().lowercase()
Joi.string().uppercase()
Joi.string().trim()

Пример

const schema = Joi.object({
  username: Joi.string()
    .alphanum()
    .min(3)
    .max(20)
    .required()
});

Number

Joi.number()

Основные ограничения

Joi.number().min(0)
Joi.number().max(100)
Joi.number().integer()
Joi.number().positive()
Joi.number().negative()
Joi.number().precision(2)

Пример

const schema = Joi.object({
  price: Joi.number()
    .positive()
    .precision(2)
    .required()
});

Boolean

Joi.boolean()

Пример

const schema = Joi.object({
  isAdmin: Joi.boolean().required()
});

Date

Joi.date()

Примеры

Joi.date().greater('now')
Joi.date().less('2027-01-01')
Joi.date().iso()

Проверка даты рождения

const schema = Joi.object({
  birthday: Joi.date()
    .less('now')
    .required()
});

Array

Joi.array()

Проверка элементов

Joi.array().items(Joi.string())

Ограничения массива

Joi.array().min(1)
Joi.array().max(10)
Joi.array().length(5)
Joi.array().unique()

Пример

const schema = Joi.object({
  tags: Joi.array()
    .items(Joi.string())
    .min(1)
    .required()
});

Object

Joi.object()

Вложенные объекты

const schema = Joi.object({
  profile: Joi.object({
    city: Joi.string(),
    country: Joi.string()
  })
});

Required, Optional и Forbidden

Обязательное поле

Joi.string().required()

Необязательное поле

Joi.string().optional()

Запрещённое поле

Joi.any().forbidden()

Пример

const schema = Joi.object({
  role: Joi.any().forbidden()
});

Значения по умолчанию

Default

Joi.string().default('user')

Пример

const schema = Joi.object({
  role: Joi.string().default('guest')
});

const result = schema.validate({});

console.log(result.value);

Результат:

{
  role: 'guest'
}

Допустимые значения

valid

Joi.string().valid('admin', 'user', 'guest')

invalid

Joi.string().invalid('root')

only

Joi.string().valid('admin').only()

Работа с null

allow

Joi.string().allow(null)

Несколько разрешённых значений

Joi.string().allow(null, '')

Empty

Преобразование значения в undefined

Joi.string().empty('')

Пример

const schema = Joi.string().empty('');

schema.validate('');

Пустая строка будет считаться отсутствующим значением.


Кастомные сообщения об ошибках

Метод messages

const schema = Joi.string().min(5).messages({
  'string.min': 'Минимальная длина — 5 символов',
  'string.empty': 'Поле обязательно'
});

validateAsync

Асинхронная валидация

await schema.validateAsync(data);

Пример

try {
  const value = await schema.validateAsync(req.body);
} catch (err) {
  console.log(err.message);
}

Опции валидации

abortEarly

По умолчанию Joi останавливается на первой ошибке.

schema.validate(data, {
  abortEarly: false
});

Пример

const schema = Joi.object({
  email: Joi.string().email().required(),
  age: Joi.number().min(18).required()
});

const result = schema.validate({}, {
  abortEarly: false
});

console.log(result.error.details);

allowUnknown

Разрешение неизвестных полей.

schema.validate(data, {
  allowUnknown: true
});

stripUnknown

Удаление неизвестных полей.

schema.validate(data, {
  stripUnknown: true
});

Пример

const schema = Joi.object({
  name: Joi.string()
});

const result = schema.validate({
  name: 'John',
  hack: true
}, {
  stripUnknown: true
});

console.log(result.value);

Результат:

{
  name: 'John'
}

convert

Автоматическое преобразование типов.

schema.validate(data, {
  convert: true
});

Пример

const schema = Joi.number();

schema.validate('10');

Строка будет преобразована в число.


Валидация query-параметров

Express.js

Express

app.get('/users', (req, res) => {
  const schema = Joi.object({
    page: Joi.number().min(1).default(1),
    lim it: Joi.number().min(1).max(100).default(10)
  });

  const { error, value } = schema.validate(req.query);

  if (error) {
    return res.status(400).json({
      error: error.message
    });
  }

  res.json(value);
});

Валидация request body

POST-запрос

app.post('/users', async (req, res) => {
  const schema = Joi.object({
    name: Joi.string().min(2).required(),
    email: Joi.string().email().required(),
    age: Joi.number().min(18)
  });

  const { error, value } = schema.validate(req.body);

  if (error) {
    return res.status(400).json({
      message: error.message
    });
  }

  res.json(value);
});

Валидация route params

Проверка ID

app.get('/users/:id', (req, res) => {
  const schema = Joi.object({
    id: Joi.number().integer().positive().required()
  });

  const { error } = schema.validate(req.params);

  if (error) {
    return res.status(400).json({
      error: 'Некорректный ID'
    });
  }

  res.send('OK');
});

Middleware для Joi

Универсальный middleware

const validate = (schema) => {
  return (req, res, next) => {
    const { error, value } = schema.validate(req.body, {
      abortEarly: false
    });

    if (error) {
      return res.status(400).json({
        errors: error.details.map(item => ({
          field: item.path.join('.'),
          message: item.message
        }))
      });
    }

    req.validated = value;

    next();
  };
};

Использование middleware

const userSchema = Joi.object({
  name: Joi.string().required(),
  email: Joi.string().email().required()
});

app.post(
  '/users',
  validate(userSchema),
  (req, res) => {
    res.json(req.validated);
  }
);

Условная валидация

when

Joi.when()

Пример

const schema = Joi.object({
  role: Joi.string().valid('user', 'admin'),

  permissions: Joi.when('role', {
    is: 'admin',
    then: Joi.array()
      .items(Joi.string())
      .min(1)
      .required(),

    otherwise: Joi.forbidden()
  })
});

Альтернативные схемы

alternatives

Joi.alternatives()

Пример

const schema = Joi.alternatives().try(
  Joi.string(),
  Joi.number()
);

Кастомная валидация

custom

const schema = Joi.string().custom((value, helpers) => {
  if (value.includes('admin')) {
    return helpers.error('string.invalid');
  }

  return value;
});

Расширение Joi

extend

const customJoi = Joi.extend((joi) => ({
  type: 'even',

  base: joi.number(),

  validate(value, helpers) {
    if (value % 2 !== 0) {
      return {
        value,
        errors: helpers.error('even.base')
      };
    }
  },

  messages: {
    'even.base': 'Число должно быть чётным'
  }
}));

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

const schema = customJoi.even();

schema.validate(10);

Работа с ошибками

error.details

const result = schema.validate(data, {
  abortEarly: false
});

console.log(result.error.details);

Структура ошибки

[
  {
    message,
    path,
    type,
    context
  }
]

Schema reuse

Повторное использование схем

const emailSchema = Joi.string()
  .email()
  .required();

const createUserSchema = Joi.object({
  email: emailSchema
});

const updateUserSchema = Joi.object({
  email: emailSchema.optional()
});

Fork

Изменение части схемы

const schema = Joi.object({
  name: Joi.string(),
  email: Joi.string()
});

const requiredSchema = schema.fork(
  ['name', 'email'],
  field => field.required()
);

Append

Добавление полей

const baseSchema = Joi.object({
  name: Joi.string()
});

const fullSchema = baseSchema.append({
  age: Joi.number()
});

Extract

Извлечение части схемы

const schema = Joi.object({
  user: Joi.object({
    email: Joi.string().email()
  })
});

const emailSchema = schema.extract('user.email');

Label

Переименование поля в ошибках

const schema = Joi.string().label('Email');

Description и Meta

Документирование схем

Joi.string()
  .description('Email пользователя')
  .meta({
    example: 'user@mail.com'
  });

Преобразование данных

trim

Joi.string().trim()

lowercase

Joi.string().lowercase()

uppercase

Joi.string().uppercase()

Валидация email

Полноценная схема

const schema = Joi.object({
  email: Joi.string()
    .email({
      minDomainSegments: 2
    })
    .required()
});

Валидация пароля

Сложный пароль

const passwordSchema = Joi.string()
  .min(8)
  .pattern(/[a-z]/)
  .pattern(/[A-Z]/)
  .pattern(/[0-9]/)
  .pattern(/[!@#$%^&*]/)
  .required();

Валидация UUID

UUID v4

Joi.string().uuid({
  version: 'uuidv4'
});

Валидация JWT

JWT Token

Joi.string().pattern(
  /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/
);

Валидация URL

Проверка URL

Joi.string().uri()

Только HTTPS

Joi.string().uri({
  scheme: ['https']
});

Валидация IP

IPv4 и IPv6

Joi.string().ip()

Валидация телефона

Регулярное выражение

Joi.string().pattern(/^\+7\d{10}$/)

Глобальные схемы API

Структура проекта

src/
├── validators/
│   ├── user.validator.js
│   ├── auth.validator.js
│   └── product.validator.js

user.validator.js

const Joi = require('joi');

exports.createUserSchema = Joi.object({
  name: Joi.string().min(2).required(),

  email: Joi.string()
    .email()
    .required(),

  password: Joi.string()
    .min(8)
    .required()
});

Интеграция с Express middleware

Готовая архитектура

src/
├── middlewares/
│   └── validate.middleware.js
├── validators/
└── routes/

validate.middleware.js

module.exports = (schema) => {
  return async (req, res, next) => {
    try {
      const value = await schema.validateAsync(req.body, {
        abortEarly: false,
        stripUnknown: true
      });

      req.validated = value;

      next();

    } catch (err) {
      return res.status(400).json({
        errors: err.details.map(item => ({
          field: item.path.join('.'),
          message: item.message
        }))
      });
    }
  };
};

Производительность Joi

Основные особенности

  • схемы желательно создавать один раз;
  • повторное создание схем на каждый запрос увеличивает нагрузку;
  • сложные регулярные выражения замедляют валидацию;
  • abortEarly: false требует больше ресурсов;
  • stripUnknown полезен для безопасности API.

Безопасность API

Joi как защитный слой

Joi помогает предотвращать:

  • передачу неожиданных полей;
  • SQL-инъекции через некорректные данные;
  • нарушения структуры JSON;
  • ошибки типов;
  • неконсистентность данных;
  • переполнение строк;
  • некорректные query-параметры.

Типичные ошибки

Отсутствие required

Joi.string()

Поле остаётся необязательным.


Игнорирование stripUnknown

Без удаления лишних полей клиент может передавать неожиданные данные.


Смешивание бизнес-логики и валидации

Плохо:

if (user.age < 18) {
  ...
}

Лучше:

Joi.number().min(18)

Лучшие практики

Централизация схем

Все схемы хранятся отдельно от роутов.


Переиспользование схем

const idSchema = Joi.number()
  .integer()
  .positive();

Нормализация данных

Joi.string().trim().lowercase()

Единый формат ошибок

{
  errors: [
    {
      field: 'email',
      message: 'Некорректный email'
    }
  ]
}

Валидация всех источников данных

Проверяются:

  • req.body
  • req.query
  • req.params
  • headers
  • cookies

Комплексный пример API-валидации

Схема регистрации

const registerSchema = Joi.object({
  name: Joi.string()
    .min(2)
    .max(50)
    .trim()
    .required(),

  email: Joi.string()
    .email()
    .lowercase()
    .required(),

  password: Joi.string()
    .min(8)
    .pattern(/[A-Z]/)
    .pattern(/[0-9]/)
    .required(),

  age: Joi.number()
    .integer()
    .min(18),

  roles: Joi.array()
    .items(
      Joi.string().valid('user', 'admin')
    )
    .default(['user'])
});

Middleware

const validate = (schema) => {
  return async (req, res, next) => {
    try {
      req.validated = await schema.validateAsync(
        req.body,
        {
          abortEarly: false,
          stripUnknown: true
        }
      );

      next();

    } catch (err) {
      return res.status(400).json({
        errors: err.details.map(e => ({
          field: e.path.join('.'),
          message: e.message
        }))
      });
    }
  };
};

Route

app.post(
  '/register',
  validate(registerSchema),
  async (req, res) => {
    const user = req.validated;

    res.json({
      success: true,
      user
    });
  }
);