Joi — библиотека для декларативной валидации данных в приложениях на JavaScript и Node.js. Основная задача Joi — проверка структуры, типов и ограничений входящих данных до начала бизнес-логики.
В контексте API Joi применяется для:
body HTTP-запросов;Типичный сценарий:
POST /users
API получает JSON:
{
"name": "Alex",
"email": "alex@mail.com",
"age": 25
}
Перед сохранением данных сервер обязан проверить:
Joi позволяет описать эти правила в виде схем.
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: ...
}
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
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()
});
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()
});
Joi.boolean()
const schema = Joi.object({
isAdmin: Joi.boolean().required()
});
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()
});
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()
});
Joi.object()
const schema = Joi.object({
profile: Joi.object({
city: Joi.string(),
country: Joi.string()
})
});
Joi.string().required()
Joi.string().optional()
Joi.any().forbidden()
const schema = Joi.object({
role: Joi.any().forbidden()
});
Joi.string().default('user')
const schema = Joi.object({
role: Joi.string().default('guest')
});
const result = schema.validate({});
console.log(result.value);
Результат:
{
role: 'guest'
}
Joi.string().valid('admin', 'user', 'guest')
Joi.string().invalid('root')
Joi.string().valid('admin').only()
Joi.string().allow(null)
Joi.string().allow(null, '')
Joi.string().empty('')
const schema = Joi.string().empty('');
schema.validate('');
Пустая строка будет считаться отсутствующим значением.
const schema = Joi.string().min(5).messages({
'string.min': 'Минимальная длина — 5 символов',
'string.empty': 'Поле обязательно'
});
await schema.validateAsync(data);
try {
const value = await schema.validateAsync(req.body);
} catch (err) {
console.log(err.message);
}
По умолчанию 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);
Разрешение неизвестных полей.
schema.validate(data, {
allowUnknown: true
});
Удаление неизвестных полей.
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'
}
Автоматическое преобразование типов.
schema.validate(data, {
convert: true
});
const schema = Joi.number();
schema.validate('10');
Строка будет преобразована в число.
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);
});
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);
});
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');
});
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();
};
};
const userSchema = Joi.object({
name: Joi.string().required(),
email: Joi.string().email().required()
});
app.post(
'/users',
validate(userSchema),
(req, res) => {
res.json(req.validated);
}
);
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()
})
});
Joi.alternatives()
const schema = Joi.alternatives().try(
Joi.string(),
Joi.number()
);
const schema = Joi.string().custom((value, helpers) => {
if (value.includes('admin')) {
return helpers.error('string.invalid');
}
return value;
});
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);
const result = schema.validate(data, {
abortEarly: false
});
console.log(result.error.details);
[
{
message,
path,
type,
context
}
]
const emailSchema = Joi.string()
.email()
.required();
const createUserSchema = Joi.object({
email: emailSchema
});
const updateUserSchema = Joi.object({
email: emailSchema.optional()
});
const schema = Joi.object({
name: Joi.string(),
email: Joi.string()
});
const requiredSchema = schema.fork(
['name', 'email'],
field => field.required()
);
const baseSchema = Joi.object({
name: Joi.string()
});
const fullSchema = baseSchema.append({
age: Joi.number()
});
const schema = Joi.object({
user: Joi.object({
email: Joi.string().email()
})
});
const emailSchema = schema.extract('user.email');
const schema = Joi.string().label('Email');
Joi.string()
.description('Email пользователя')
.meta({
example: 'user@mail.com'
});
Joi.string().trim()
Joi.string().lowercase()
Joi.string().uppercase()
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();
Joi.string().uuid({
version: 'uuidv4'
});
Joi.string().pattern(
/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/
);
Joi.string().uri()
Joi.string().uri({
scheme: ['https']
});
Joi.string().ip()
Joi.string().pattern(/^\+7\d{10}$/)
src/
├── validators/
│ ├── user.validator.js
│ ├── auth.validator.js
│ └── product.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()
});
src/
├── middlewares/
│ └── validate.middleware.js
├── validators/
└── routes/
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
}))
});
}
};
};
abortEarly: false требует больше ресурсов;stripUnknown полезен для безопасности API.Joi помогает предотвращать:
Joi.string()
Поле остаётся необязательным.
Без удаления лишних полей клиент может передавать неожиданные данные.
Плохо:
if (user.age < 18) {
...
}
Лучше:
Joi.number().min(18)
Все схемы хранятся отдельно от роутов.
const idSchema = Joi.number()
.integer()
.positive();
Joi.string().trim().lowercase()
{
errors: [
{
field: 'email',
message: 'Некорректный email'
}
]
}
Проверяются:
req.bodyreq.queryreq.paramsheadersconst 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'])
});
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
}))
});
}
};
};
app.post(
'/register',
validate(registerSchema),
async (req, res) => {
const user = req.validated;
res.json({
success: true,
user
});
}
);