Схемы валидации со временем изменяются: появляются новые поля, старые параметры становятся необязательными, меняются ограничения и бизнес-правила. В больших приложениях необходимо поддерживать совместимость между разными версиями API, конфигураций, событий и пользовательских данных.
В библиотеке Joi существуют механизмы, позволяющие организовать эволюцию схем без дублирования кода и без разрушения уже работающих контрактов.
Типичный сценарий:
const userSchemaV1 = Joi.object({
username: Joi.string().required(),
email: Joi.string().email().required()
});
Появляется новое поле:
const userSchemaV2 = Joi.object({
username: Joi.string().required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(18)
});
Поле username заменяется на login:
const userSchemaV3 = Joi.object({
login: Joi.string().required(),
email: Joi.string().email().required(),
age: Joi.number().integer().min(18)
});
Если хранить отдельную схему для каждой версии, код быстро начинает дублироваться и становится трудно поддерживаемым.
Наиболее распространённый подход — создание базовой схемы и её расширение.
const baseUserSchema = Joi.object({
email: Joi.string().email().required()
});
const userSchemaV1 = baseUserSchema.keys({
username: Joi.string().required()
});
const userSchemaV2 = baseUserSchema.keys({
username: Joi.string().required(),
age: Joi.number().integer().min(18)
});
const userSchemaV3 = baseUserSchema.keys({
login: Joi.string().required(),
age: Joi.number().integer().min(18)
});
keys()Метод keys() добавляет или переопределяет поля
объекта.
const schema = Joi.object({
name: Joi.string()
});
const extended = schema.keys({
age: Joi.number()
});
const schema = Joi.object({
age: Joi.number()
});
const strictSchema = schema.keys({
age: Joi.number().min(18).required()
});
append()append() похож на keys(), но используется
только для добавления новых ключей.
const schema = Joi.object({
name: Joi.string()
});
const extended = schema.append({
age: Joi.number()
});
keys() и
append()keys()append()fork()fork() позволяет изменять правила сразу для нескольких
полей.
Это один из важнейших инструментов версионирования.
const schema = Joi.object({
firstName: Joi.string(),
lastName: Joi.string(),
email: Joi.string().email()
});
Все поля необязательны.
firstName и lastName становятся
обязательными.
const v2Schema = schema.fork(
['firstName', 'lastName'],
field => field.required()
);
{
firstName: 'John',
lastName: 'Doe'
}
валиден, а
{
firstName: 'John'
}
уже вызовет ошибку.
fork() особенно полезен при миграции старых API.
const schema = Joi.object({
id: Joi.number(),
name: Joi.string(),
email: Joi.string().email(),
phone: Joi.string()
});
const strictSchema = schema.fork(
['id', 'name', 'email', 'phone'],
field => field.required()
);
alter()alter() — основной механизм встроенного версионирования
в Joi.
Он позволяет заранее описать варианты изменения схемы.
const schema = Joi.object({
username: Joi.string().alter({
v2: schema => schema.required(),
v3: schema => schema.min(5)
})
});
tailor()tailor() применяет изменения, описанные через
alter().
const v2Schema = schema.tailor('v2');
const v3Schema = schema.tailor('v3');
v2username обязателен
v3username должен содержать минимум 5 символов
alter()const userSchema = Joi.object({
username: Joi.string().alter({
v1: schema => schema.required(),
v2: schema => schema.min(5).required(),
v3: schema => schema.forbidden()
}),
login: Joi.string().alter({
v1: schema => schema.forbidden(),
v2: schema => schema.forbidden(),
v3: schema => schema.required()
}),
email: Joi.string().email().required()
});
const v1 = userSchema.tailor('v1');
username обязателен;login запрещён.const v2 = userSchema.tailor('v2');
username обязателен;login запрещён.const v3 = userSchema.tailor('v3');
username запрещён;login обязателен.tailor() принимает массив.
const schema = Joi.string().alter({
required: s => s.required(),
short: s => s.max(10)
});
const result = schema.tailor(['required', 'short']);
const schema = Joi.object({
user: Joi.object({
profile: Joi.object({
name: Joi.string().alter({
v2: s => s.required()
}),
age: Joi.number().alter({
v3: s => s.min(18)
})
})
})
});
const v3Schema = schema.tailor('v3');
Все вложенные alter() будут обработаны
автоматически.
forbidden()const schema = Joi.object({
oldField: Joi.string().alter({
v2: s => s.forbidden()
})
});
Поле разрешено.
Поле вызовет ошибку:
{
oldField: 'value'
}
Иногда старая версия должна продолжать работать.
const schema = Joi.object({
username: Joi.string(),
login: Joi.string()
}).oxor('username', 'login');
oxor()Разрешает наличие только одного из полей.
Допустимо:
{
username: 'john'
}
или
{
login: 'john'
}
Недопустимо:
{
username: 'john',
login: 'john'
}
warning()const schema = Joi.object({
username: Joi.string().warning('deprecated.username')
});
const result = schema.validate(
{ username: 'john' },
{ warnings: true }
);
console.log(result.warning);
При переходе между версиями API часто требуется сохранить старое имя параметра.
rename()const schema = Joi.object({
login: Joi.string()
}).rename('username', 'login');
{
username: 'john'
}
{
login: 'john'
}
rename()aliasСохраняет старое поле.
.rename('username', 'login', {
alias: true
});
overrideПозволяет перезаписывать существующее значение.
.rename('username', 'login', {
override: true
});
ignoreUndefinedИгнорирует отсутствующее поле.
.rename('username', 'login', {
ignoreUndefined: true
});
Иногда версия приходит в данных запроса.
when()const schema = Joi.object({
version: Joi.number().required(),
username: Joi.when('version', {
is: 1,
then: Joi.required(),
otherwise: Joi.forbidden()
}),
login: Joi.when('version', {
is: 2,
then: Joi.required(),
otherwise: Joi.forbidden()
})
});
const schemas = {
v1: userSchema.tailor('v1'),
v2: userSchema.tailor('v2'),
v3: userSchema.tailor('v3')
};
function validate(version, data) {
return schemas[version].validate(data);
}
В крупных проектах схемы обычно организуются по каталогам.
schemas/
├── base/
│ └── user.js
│
├── v1/
│ └── user.js
│
├── v2/
│ └── user.js
│
└── v3/
└── user.js
Иногда удобнее хранить всё в одном месте.
schemas/
└── user/
├── base.js
├── v1.js
├── v2.js
└── v3.js
const v1 = Joi.array().items(
Joi.string()
);
const v2 = Joi.array().items(
Joi.object({
value: Joi.string()
})
);
const schema = Joi.array().items(
Joi.alternatives().try(
Joi.string(),
Joi.object({
value: Joi.string()
})
)
);
alternatives()alternatives() полезен для переходных периодов.
const schema = Joi.alternatives().try(
Joi.string(),
Joi.number()
);
{
age: "25"
}
{
age: 25
}
const schema = Joi.object({
age: Joi.alternatives().try(
Joi.number(),
Joi.string().pattern(/^\d+$/)
)
});
prefs()Разные версии API могут иметь разные настройки валидации.
const schema = Joi.object({
name: Joi.string()
});
const strictSchema = schema.prefs({
allowUnknown: false
});
const relaxedSchema = schema.prefs({
allowUnknown: true
});
Soft deprecation — постепенное отключение функциональности.
Поле полностью поддерживается.
username: Joi.string()
Поле помечается предупреждением.
username: Joi.string()
.warning('deprecated')
Поле становится необязательным.
username: Joi.string()
Поле запрещается.
username: Joi.forbidden()
Иногда между клиентами и API создаётся слой совместимости.
function normalize(data) {
if (data.username) {
data.login = data.username;
delete data.username;
}
return data;
}
const result = schema.validate(
normalize(payload)
);
alter()
и fork()const schema = Joi.object({
name: Joi.string().alter({
create: s => s.required(),
update: s => s.optional()
}),
email: Joi.string().email()
});
const updateSchema = schema
.tailor('upd ate')
.fork(['email'], s => s.required());
Создание схем — сравнительно дорогая операция.
function validate(version, data) {
const schema = createSchema(version);
return schema.validate(data);
}
Схема создаётся при каждом запросе.
const schemas = {
v1: createSchema('v1'),
v2: createSchema('v2')
};
function validate(version, data) {
return schemas[version].validate(data);
}
Иногда используется ленивое создание.
const cache = new Map();
function getSchema(version) {
if (!cache.has(version)) {
cache.se t(version, buildSchema(version));
}
return cache.get(version);
}
Каждая версия должна тестироваться отдельно.
describe('v2 schema', () => {
test('should require username', () => {
const result = schema.validate({});
expect(result.error).toBeDefined();
});
});
Иногда полезно фиксировать описание схемы.
expect(schema.describe()).toMatchSnapshot();
describe()describe() возвращает внутреннее представление
схемы.
console.log(schema.describe());
Плохо:
const v1 = Joi.object({...});
const v2 = Joi.object({...});
const v3 = Joi.object({...});
Большие объёмы копипаста приводят к расхождению правил.
Резкое удаление старых параметров ломает клиентов.
Нужен переходный период, когда поддерживаются обе версии.
Плохо:
if (apiVersion === 2 && user.role === 'admin')
Схема должна отвечать только за структуру и ограничения данных.
Общие поля должны храниться централизованно.
alter() для эволюцииalter() значительно снижает дублирование.
tailor() вместо ручного ветвленияПлохо:
if (version === 1) {
...
}
Лучше:
schema.tailor(version);
Deprecated-поля лучше удалять постепенно.
Старые версии API желательно отделять от новых модулей.
Каждое изменение схемы должно сопровождаться: