При росте приложения схемы валидации быстро превращаются в трудно
поддерживаемый монолит. Повторяющиеся правила, длинные цепочки
.object(), дублирование логики между API и внутренними
сервисами приводят к усложнению кода и увеличению количества ошибок.
Модульная организация валидаторов в Joi решает несколько задач:
Типичная структура крупного Node.js-приложения:
src/
├── validators/
│ ├── common/
│ │ ├── pagination.validator.js
│ │ ├── id.validator.js
│ │ └── password.validator.js
│ │
│ ├── user/
│ │ ├── user.create.validator.js
│ │ ├── user.update.validator.js
│ │ └── user.login.validator.js
│ │
│ ├── product/
│ │ ├── product.create.validator.js
│ │ └── product.update.validator.js
│ │
│ └── index.js
│
├── services/
├── controllers/
└── routes/
Главная идея — группировка схем по доменным областям.
// validators/user/user.create.validator.js
const Joi = require('joi');
const createUserSchema = Joi.object({
username: Joi.string()
.min(3)
.max(30)
.required(),
email: Joi.string()
.email()
.required(),
password: Joi.string()
.min(8)
.required()
});
module.exports = createUserSchema;
Использование:
const createUserSchema = require('../validators/user/user.create.validator');
const result = createUserSchema.validate(req.body);
Для удобства импорта создают общий индексный файл.
// validators/index.js
module.exports = {
user: {
create: require('./user/user.create.validator'),
update: require('./user/user.update.validator'),
login: require('./user/user.login.validator')
},
product: {
create: require('./product/product.create.validator')
}
};
Использование:
const validators = require('../validators');
validators.user.create.validate(data);
Во многих проектах повторяются одинаковые правила:
Вместо дублирования создаются общие модули.
// validators/common/id.validator.js
const Joi = require('joi');
module.exports = Joi.string()
.uuid()
.required();
Использование:
const Joi = require('joi');
const idSchema = require('../common/id.validator');
const schema = Joi.object({
userId: idSchema
});
// validators/common/password.validator.js
const Joi = require('joi');
module.exports = Joi.string()
.min(8)
.max(64)
.pattern(/[A-Z]/)
.pattern(/[a-z]/)
.pattern(/[0-9]/)
.required();
Использование:
const passwordSchema = require('../common/password.validator');
const schema = Joi.object({
password: passwordSchema
});
Joi позволяет собирать сложные схемы из маленьких компонентов.
// validators/user/base-user.schema.js
const Joi = require('joi');
module.exports = {
username: Joi.string().min(3).max(30),
email: Joi.string().email(),
age: Joi.number().integer().min(18)
};
// validators/user/user.create.validator.js
const Joi = require('joi');
const baseUser = require('./base-user.schema');
const schema = Joi.object({
...baseUser,
password: Joi.string()
.min(8)
.required()
});
module.exports = schema;
// validators/user/user.update.validator.js
const Joi = require('joi');
const baseUser = require('./base-user.schema');
const schema = Joi.object({
...baseUser
}).min(1);
module.exports = schema;
Метод .concat() объединяет схемы.
const Joi = require('joi');
const baseSchema = Joi.object({
name: Joi.string().required()
});
const extraSchema = Joi.object({
age: Joi.number().required()
});
const finalSchema = baseSchema.concat(extraSchema);
Результат:
{
name: string required,
age: number required
}
// validators/common/address.schema.js
const Joi = require('joi');
module.exports = Joi.object({
city: Joi.string().required(),
street: Joi.string().required(),
zipCode: Joi.string().required()
});
const Joi = require('joi');
const addressSchema = require('../common/address.schema');
const schema = Joi.object({
companyName: Joi.string().required(),
address: addressSchema.required()
});
Иногда схема зависит от параметров.
// validators/factories/string-length.factory.js
const Joi = require('joi');
module.exports = (min, max) => {
return Joi.string().min(min).max(max);
};
Использование:
const stringLength = require('./factories/string-length.factory');
const schema = Joi.object({
title: stringLength(5, 100),
shortTitle: stringLength(2, 20)
});
const Joi = require('joi');
function createUserSchema(role) {
const base = {
username: Joi.string().required()
};
if (role === 'admin') {
base.permissions = Joi.array()
.items(Joi.string())
.required();
}
return Joi.object(base);
}
module.exports = createUserSchema;
Использование:
const createUserSchema = require('./user.factory');
const adminSchema = createUserSchema('admin');
const userSchema = createUserSchema('user');
Одна из распространённых ошибок — использование одной схемы для всех операций.
Неправильно:
const userSchema = Joi.object({
username: Joi.string().required(),
email: Joi.string().required(),
password: Joi.string().required()
});
Проблемы:
const createSchema = Joi.object({
username: Joi.string().required(),
email: Joi.string().required(),
password: Joi.string().required()
});
const updateSchema = Joi.object({
username: Joi.string(),
email: Joi.string(),
password: Joi.string()
}).min(1);
const loginSchema = Joi.object({
email: Joi.string().required(),
password: Joi.string().required()
});
В Express валидаторы обычно интегрируются через middleware.
// middleware/validate.js
module.exports = (schema) => {
return (req, res, next) => {
const { error, value } = schema.validate(req.body, {
abortEarly: false,
stripUnknown: true
});
if (error) {
return res.status(400).json({
errors: error.details
});
}
req.body = value;
next();
};
};
const express = require('express');
const validate = require('../middleware/validate');
const createUserSchema = require('../validators/user/user.create.validator');
router.post(
'/users',
validate(createUserSchema),
controller.create
);
// validators/messages.js
module.exports = {
'string.empty': 'Поле не может быть пустым',
'any.required': 'Поле обязательно',
'string.email': 'Некорректный email'
};
const Joi = require('joi');
const messages = require('../messages');
const schema = Joi.object({
email: Joi.string()
.email()
.required()
}).messages(messages);
Joi поддерживает расширение через .extend().
const JoiBase = require('joi');
const Joi = JoiBase.extend((joi) => ({
type: 'objectId',
base: joi.string(),
validate(value, helpers) {
if (!/^[0-9a-fA-F]{24}$/.test(value)) {
return {
value,
errors: helpers.error('objectId.invalid')
};
}
},
messages: {
'objectId.invalid': 'Некорректный ObjectId'
}
}));
Использование:
const schema = Joi.object({
userId: Joi.objectId().required()
});
order/
├── order-item.schema.js
├── customer.schema.js
├── payment.schema.js
└── order.create.validator.js
// order-item.schema.js
const Joi = require('joi');
module.exports = Joi.object({
productId: Joi.string().required(),
quantity: Joi.number()
.integer()
.min(1)
.required()
});
// customer.schema.js
const Joi = require('joi');
module.exports = Joi.object({
name: Joi.string().required(),
phone: Joi.string().required()
});
// order.create.validator.js
const Joi = require('joi');
const orderItemSchema = require('./order-item.schema');
const customerSchema = require('./customer.schema');
module.exports = Joi.object({
customer: customerSchema.required(),
items: Joi.array()
.items(orderItemSchema)
.min(1)
.required()
});
Валидацию query-параметров лучше хранить отдельно.
// validators/common/pagination.validator.js
const Joi = require('joi');
module.exports = Joi.object({
page: Joi.number()
.integer()
.min(1)
.default(1),
limit: Joi.number()
.integer()
.min(1)
.max(100)
.default(10)
});
const paginationSchema = require('../validators/common/pagination.validator');
paginationSchema.validate(req.query);
В крупных приложениях создаются отдельные схемы для разных частей HTTP-запроса.
module.exports = {
body: Joi.object({
email: Joi.string().email().required()
}),
params: Joi.object({
id: Joi.string().uuid().required()
}),
query: Joi.object({
expand: Joi.boolean()
})
};
module.exports = (schemas) => {
return (req, res, next) => {
const sections = ['body', 'params', 'query'];
for (const section of sections) {
if (!schemas[section]) {
continue;
}
const { error, value } = schemas[section]
.validate(req[section]);
if (error) {
return res.status(400).json({
section,
error: error.details
});
}
req[section] = value;
}
next();
};
};
При развитии API старые схемы часто должны оставаться неизменными.
validators/
├── v1/
│ └── user/
├── v2/
│ └── user/
module.exports = Joi.object({
username: Joi.string().required()
});
module.exports = Joi.object({
username: Joi.string().required(),
displayName: Joi.string()
});
Плохая практика:
if (!data.email) {
throw new Error('Email required');
}
if (data.password.length < 8) {
throw new Error('Password too short');
}
Хорошая практика:
const schema = Joi.object({
email: Joi.string()
.email()
.required(),
password: Joi.string()
.min(8)
.required()
});
Модульность особенно эффективна именно в декларативном стиле.
const createUserSchema = require('./user.create.validator');
describe('User create validator', () => {
test('валидный объект проходит проверку', () => {
const result = createUserSchema.validate({
username: 'admin',
email: 'admin@test.com',
password: 'Password123'
});
expect(result.error).toBeUndefined();
});
test('невалидный email вызывает ошибку', () => {
const result = createUserSchema.validate({
username: 'admin',
email: 'wrong-email',
password: 'Password123'
});
expect(result.error).toBeDefined();
});
});
Схемы не должны содержать:
Неправильно:
Joi.string().custom(async (value) => {
const user = await db.findUser(value);
if (user) {
throw new Error('User exists');
}
return value;
});
Лучше:
В monorepo схемы часто выносятся в отдельный пакет.
packages/
├── api/
├── frontend/
└── validation/
// packages/validation/index.js
module.exports = {
userSchemas: require('./user'),
orderSchemas: require('./order')
};
Это позволяет использовать одинаковые схемы:
В очень больших системах создаётся централизованный реестр схем.
const registry = {
USER_CREATE: require('./user/user.create.validator'),
USER_UPDATE: require('./user/user.update.validator'),
PRODUCT_CREATE: require('./product/product.create.validator')
};
module.exports = registry;
Использование:
const schemas = require('./registry');
schemas.USER_CREATE.validate(data);
Для избежания циклических зависимостей используется
Joi.lazy().
const Joi = require('joi');
const categorySchema = Joi.object({
name: Joi.string().required(),
children: Joi.array().items(
Joi.lazy(() => categorySchema)
)
});
// validators/helpers/required-string.js
const Joi = require('joi');
module.exports = () => {
return Joi.string().trim().required();
};
const requiredString = require('../helpers/required-string');
const schema = Joi.object({
firstName: requiredString(),
lastName: requiredString()
});
Слишком сильное дробление схем может ухудшить поддержку.
Плохо:
validators/
├── user/
│ ├── fields/
│ │ ├── username.js
│ │ ├── email.js
│ │ ├── age.js
Проблемы:
Хороший баланс:
Хорошо:
user.create.validator.js
user.update.validator.js
user.login.validator.js
Плохо:
validator1.js
schema-final.js
new-validator.js
Схема отвечает только за:
Файл на 1000 строк со всеми схемами проекта быстро становится неуправляемым.
Плохо:
email: Joi.string().email().required()
в десятках файлов подряд.
Лучше:
const emailSchema = require('../common/email.schema');
const options = {
abortEarly: false,
stripUnknown: true,
convert: true
};
validators/
├── common/
│ ├── email.schema.js
│ ├── phone.schema.js
│ ├── id.schema.js
│ └── pagination.schema.js
│
├── user/
│ ├── base-user.schema.js
│ ├── user.create.validator.js
│ ├── user.update.validator.js
│ ├── user.login.validator.js
│ └── index.js
│
├── order/
│ ├── order-item.schema.js
│ ├── address.schema.js
│ ├── payment.schema.js
│ ├── order.create.validator.js
│ └── order.update.validator.js
│
├── middleware/
│ └── validate.js
│
├── helpers/
│ └── required-string.js
│
└── index.js